Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-13 15:20:45 +03:00
commit 1056edbfa2
84 changed files with 891 additions and 215 deletions

View file

@ -87,6 +87,7 @@ dependencies {
implementation(projects.features.swap.api)
implementation(projects.features.swap.presentation)
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.data)
implementation(projects.features.tester.api)
implementation(projects.features.tester.impl)

View file

@ -8,6 +8,7 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.settings.*
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.tap.domain.TangemSdkManager
import com.tangem.tap.domain.settings.DefaultLegacySettingsRepository
import dagger.Module
@ -103,4 +104,20 @@ internal object SettingsDomainModule {
fun provideIsWalletsScrollPreviewEnabled(settingsRepository: SettingsRepository): IsWalletsScrollPreviewEnabled {
return IsWalletsScrollPreviewEnabled(settingsRepository = settingsRepository)
}
@Provides
@ViewModelScoped
fun provideShouldShowSwapPromoWalletUseCase(
swapPromoRepository: SwapPromoRepository,
): ShouldShowSwapPromoWalletUseCase {
return ShouldShowSwapPromoWalletUseCase(swapPromoRepository)
}
@Provides
@ViewModelScoped
fun provideShouldShowSwapPromoTokenUseCase(
swapPromoRepository: SwapPromoRepository,
): ShouldShowSwapPromoTokenUseCase {
return ShouldShowSwapPromoTokenUseCase(swapPromoRepository)
}
}

View file

@ -1,9 +1,11 @@
package com.tangem.tap.di.domain
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.repository.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -83,6 +85,9 @@ internal object TokensDomainModule {
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
marketCryptoCurrencyRepository: MarketCryptoCurrencyRepository,
swapRepository: SwapRepository,
showSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
dispatchers: CoroutineDispatcherProvider,
): GetCurrencyWarningsUseCase {
return GetCurrencyWarningsUseCase(
@ -90,6 +95,9 @@ internal object TokensDomainModule {
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
marketCryptoCurrencyRepository = marketCryptoCurrencyRepository,
swapRepository = swapRepository,
showSwapPromoTokenUseCase = showSwapPromoTokenUseCase,
dispatchers = dispatchers,
)
}

View file

@ -21,6 +21,10 @@ class DemoTransactionSender(private val walletManager: WalletManager) : Transact
)
}
override suspend fun estimateFee(amount: Amount): Result<TransactionFee> {
return getFee(amount, walletManager.wallet.address)
}
override suspend fun send(transactionData: TransactionData, signer: TransactionSigner): SimpleResult {
val signerResponse = signer.sign(
hash = getDataToSign(),

View file

@ -42,6 +42,10 @@ object PreferencesKeys {
val WALLETS_BALANCES_STATES_KEY by lazy { stringPreferencesKey(name = "walletsBalancesStates") }
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
val IS_WALLET_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isWalletSwapPromoShown") }
val IS_TOKEN_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isTokenSwapPromoShown") }
}
/** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore<Preferences> */

View file

@ -244,6 +244,8 @@
<string name="main_promotion_credited">1INCH токены будут зачислены на адрес вашего кошелька в сети %s в течение 48 часов</string>
<string name="main_scan_card_warning_view_subtitle">Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту</string>
<string name="main_scan_card_warning_view_title">Отсканируйте карту</string>
<string name="main_swap_promotion_message">Обменивайте свои цифровые активы между различными сетями</string>
<string name="main_swap_promotion_title">Кроссчейн-своп теперь доступен</string>
<string name="main_tokens">Токены</string>
<string name="manage_tokens_add">Добавить</string>
<string name="manage_tokens_edit">Изменить</string>
@ -539,6 +541,9 @@
<string name="token_details_unable_hide_alert_message">Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети.</string>
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
<string name="token_item_no_rate">Нет цены</string>
<string name="token_swap_promotion_message">Обменяйте этот токен на другие активы в вашем портфеле</string>
<string name="token_swap_promotion_title">Представляем кроссчейн-своп</string>
<string name="token_swap_promotion_button">Обменять</string>
<string name="transaction_history_contract_address">контракт: %s</string>
<string name="transaction_history_empty_transactions">У вас еще нет транзакций</string>
<string name="transaction_history_error_failed_to_load">Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию.</string>

View file

@ -263,6 +263,8 @@
<string name="main_promotion_credited">1INCH tokens will be credited to your %s wallet address within 48 hours</string>
<string name="main_scan_card_warning_view_subtitle">To access all the networks you need to scan the card</string>
<string name="main_scan_card_warning_view_title">Scan your card</string>
<string name="main_swap_promotion_message">Swap multiple currencies across several blockchains</string>
<string name="main_swap_promotion_title">Cross-chain swaps are now available</string>
<string name="main_tokens">Tokens</string>
<string name="manage_tokens_add">Add</string>
<string name="manage_tokens_edit">Edit</string>
@ -569,6 +571,9 @@
<string name="token_details_unable_hide_alert_message">The %1$s token is the main currency on the %2$s network and cannot be hidden as long as you have other tokens on this network in the list.</string>
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
<string name="token_item_no_rate">No rate</string>
<string name="token_swap_promotion_message">Exchange this token for other assets in your portfolio</string>
<string name="token_swap_promotion_title">Introducing cross-chain swaps</string>
<string name="token_swap_promotion_button">Exchange now</string>
<string name="transaction_history_contract_address">contract: %s</string>
<string name="transaction_history_empty_transactions">You don\'t have any transactions yet</string>
<string name="transaction_history_error_failed_to_load">Failed to load transaction history.\nClick on reload button to update the information.</string>

View file

@ -6,12 +6,13 @@ import com.tangem.core.ui.extensions.TextReference
/**
* Notification component state
*
* @property title title
* @property subtitle subtitle
* @property iconResId icon resource id
* @property buttonsState buttons state
* @property onClick lambda be invoked when notification is clicked
* @property onCloseClick lambda be invoked when close button is clicked
* @property title title
* @property subtitle subtitle
* @property iconResId icon resource id
* @property backgroundResId background resource id
* @property buttonsState buttons state
* @property onClick lambda be invoked when notification is clicked
* @property onCloseClick lambda be invoked when close button is clicked
*
[REDACTED_AUTHOR]
*/
@ -19,6 +20,7 @@ data class NotificationConfig(
val title: TextReference,
val subtitle: TextReference,
@DrawableRes val iconResId: Int,
@DrawableRes val backgroundResId: Int? = null,
val buttonsState: ButtonsState? = null,
val onClick: (() -> Unit)? = null,
val onCloseClick: (() -> Unit)? = null,
@ -33,7 +35,11 @@ data class NotificationConfig(
val onClick: () -> Unit,
) : ButtonsState()
data class SecondaryButtonConfig(val text: TextReference, val onClick: () -> Unit) : ButtonsState()
data class SecondaryButtonConfig(
val text: TextReference,
@DrawableRes val iconResId: Int? = null,
val onClick: () -> Unit,
) : ButtonsState()
data class PairButtonsConfig(
val primaryText: TextReference,

View file

@ -0,0 +1,170 @@
package com.tangem.core.ui.components.notifications
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
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
/**
* Custom notification with image background
* @see [Swap Promo](https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=8713-6307&mode=dev)
*/
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
@Composable
fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier = Modifier) {
val button = config.buttonsState as? NotificationConfig.ButtonsState.SecondaryButtonConfig
ConstraintLayout(
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62)
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium),
) {
val (iconRef, titleRef, subtitleRef, closeIconRef, buttonRef, backgroundRef) = createRefs()
val spacing2 = TangemTheme.dimens.spacing2
val spacing12 = TangemTheme.dimens.spacing12
val spacing14 = TangemTheme.dimens.spacing14
Image(
painter = painterResource(config.backgroundResId ?: R.drawable.img_swap_promo_banner_background),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.constrainAs(backgroundRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
visibility = if (config.backgroundResId == null) Visibility.Gone else Visibility.Visible
},
)
Image(
painter = painterResource(config.iconResId),
contentDescription = null,
modifier = Modifier.constrainAs(iconRef) {
start.linkTo(parent.start, spacing12)
linkTo(titleRef.top, subtitleRef.bottom, bias = 0.1f)
},
)
Text(
text = config.title.resolveReference(),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.constantWhite,
modifier = Modifier.constrainAs(titleRef) {
top.linkTo(parent.top, spacing12)
start.linkTo(iconRef.end, spacing12)
end.linkTo(closeIconRef.start, spacing2)
width = Dimension.fillToConstraints
},
)
Text(
text = config.subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.constantWhite,
modifier = Modifier.constrainAs(subtitleRef) {
top.linkTo(titleRef.bottom, spacing2)
start.linkTo(iconRef.end, spacing12)
end.linkTo(parent.end, spacing12)
bottom.linkTo(buttonRef.top, spacing12, spacing14)
width = Dimension.fillToConstraints
},
)
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors.text.constantWhite,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.constrainAs(closeIconRef) {
top.linkTo(parent.top, spacing12)
end.linkTo(parent.end, spacing12)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) {
config.onCloseClick?.invoke()
},
)
SecondaryButtonIconStart(
text = button?.text?.resolveReference().orEmpty(),
iconResId = button?.iconResId ?: R.drawable.ic_exchange_vertical_24,
onClick = button?.onClick ?: {},
modifier = Modifier.constrainAs(buttonRef) {
start.linkTo(parent.start, spacing12)
end.linkTo(parent.end, spacing12)
bottom.linkTo(parent.bottom, spacing12)
width = Dimension.fillToConstraints
visibility = if (button == null) {
Visibility.Gone
} else {
Visibility.Visible
}
},
)
}
}
//region preview
@Preview
@Composable
private fun NotificationWithBackgroundPreview(
@PreviewParameter(NotificationWithBackgroundPreviewProvider::class) config: NotificationConfig,
) {
TangemTheme {
NotificationWithBackground(config = config)
}
}
private class NotificationWithBackgroundPreviewProvider : PreviewParameterProvider<NotificationConfig> {
override val values: Sequence<NotificationConfig>
get() = sequenceOf(
NotificationConfig(
title = resourceReference(id = R.string.main_swap_promotion_title),
subtitle = resourceReference(id = R.string.main_swap_promotion_message),
iconResId = R.drawable.ic_swap_promo_34,
backgroundResId = R.drawable.img_swap_promo_banner_background,
),
NotificationConfig(
title = resourceReference(id = R.string.token_swap_promotion_title),
subtitle = stringReference(
"Swap multiple currencies between any chains you wish. Swap multiple " +
"currencies between any chains you wish. Swap multiple " +
"currencies between any chains you wish. Commission free period " +
"till Dec 31.",
),
iconResId = R.drawable.ic_swap_promo_34,
backgroundResId = R.drawable.img_swap_promo_banner_background,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = R.string.token_swap_promotion_button),
onClick = {},
),
),
)
}
//endregion

View file

@ -71,7 +71,6 @@ fun LazyListScope.txHistoryItems(
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.contentItems(
txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>,
isBalanceHidden: Boolean,
@ -93,7 +92,6 @@ private fun LazyListScope.contentItems(
state = item,
isBalanceHidden = isBalanceHidden,
modifier = modifier
.animateItemPlacement()
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = txHistoryItems.itemSnapshotList.lastIndex,

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="34dp"
android:height="34dp"
android:viewportWidth="34"
android:viewportHeight="34">
<path
android:pathData="M22,0C28.628,0 34,5.373 34,12C34,18.628 28.628,24 22,24C15.373,24 10,18.628 10,12C10,5.373 15.373,0 22,0Z"
android:fillColor="#F5AC37"/>
<path
android:pathData="M22.442,12.847H27.002C27.099,12.847 27.145,12.847 27.152,12.72C27.189,12.256 27.189,11.79 27.152,11.325C27.152,11.235 27.107,11.198 27.01,11.198H17.935C17.823,11.198 17.792,11.235 17.792,11.34V12.675C17.792,12.847 17.792,12.847 17.972,12.847H22.442ZM26.643,9.638C26.656,9.604 26.656,9.566 26.643,9.533C26.567,9.367 26.477,9.209 26.372,9.06C26.214,8.807 26.028,8.573 25.817,8.363C25.717,8.236 25.601,8.122 25.472,8.025C24.823,7.473 24.051,7.082 23.221,6.885C22.803,6.791 22.375,6.746 21.947,6.75H17.919C17.807,6.75 17.792,6.795 17.792,6.893V9.555C17.792,9.667 17.792,9.698 17.934,9.698H26.589C26.589,9.698 26.664,9.682 26.679,9.638H26.642H26.643ZM26.643,14.407C26.515,14.393 26.387,14.393 26.26,14.407H17.942C17.83,14.407 17.792,14.407 17.792,14.558V17.16C17.792,17.28 17.792,17.31 17.942,17.31H21.782C21.966,17.325 22.149,17.312 22.329,17.273C22.887,17.233 23.435,17.112 23.958,16.913C24.147,16.847 24.331,16.761 24.504,16.658H24.557C25.457,16.19 26.188,15.455 26.649,14.552C26.649,14.552 26.702,14.438 26.643,14.408V14.407ZM16.285,18.66V18.615V16.868V16.275V14.513C16.285,14.415 16.285,14.4 16.165,14.4H14.538C14.447,14.4 14.41,14.4 14.41,14.281V12.856H16.15C16.247,12.856 16.285,12.856 16.285,12.728V11.318C16.285,11.228 16.285,11.206 16.165,11.206H14.538C14.447,11.206 14.41,11.206 14.41,11.086V9.766C14.41,9.683 14.41,9.661 14.53,9.661H16.142C16.255,9.661 16.285,9.661 16.285,9.519V5.476C16.285,5.356 16.285,5.326 16.435,5.326H22.06C22.468,5.342 22.874,5.387 23.275,5.461C24.102,5.614 24.896,5.909 25.622,6.331C26.104,6.615 26.547,6.957 26.942,7.351C27.239,7.66 27.507,7.993 27.744,8.349C27.98,8.709 28.177,9.094 28.33,9.496C28.349,9.601 28.45,9.672 28.555,9.654H29.897C30.069,9.654 30.069,9.654 30.077,9.819V11.049C30.077,11.169 30.032,11.199 29.912,11.199H28.876C28.772,11.199 28.741,11.199 28.749,11.334C28.79,11.791 28.79,12.25 28.749,12.706C28.749,12.834 28.749,12.849 28.892,12.849H30.076C30.129,12.917 30.076,12.984 30.076,13.052C30.084,13.139 30.084,13.227 30.076,13.314V14.222C30.076,14.349 30.039,14.387 29.926,14.387H28.509C28.41,14.368 28.314,14.431 28.291,14.53C27.954,15.407 27.413,16.194 26.716,16.824C26.461,17.054 26.193,17.27 25.913,17.469C25.613,17.642 25.321,17.822 25.013,17.965C24.447,18.219 23.853,18.407 23.243,18.527C22.664,18.63 22.077,18.677 21.488,18.669H16.283V18.662L16.285,18.66Z"
android:fillColor="#FEFEFD"/>
<path
android:pathData="M8.063,10.661C3.37,12.29 0,16.751 0,22C0,28.627 5.373,34 12,34C17.249,34 21.71,30.631 23.339,25.937C22.898,25.979 22.452,26 22,26C14.269,26 8,19.732 8,12C8,11.548 8.021,11.101 8.063,10.661Z"
android:fillColor="#8247E5"
android:fillType="evenOdd"/>
<path
android:pathData="M8.324,15.007C8.19,15.022 8.055,15.061 7.927,15.125L4.501,17.131C4.167,17.298 4,17.632 4,17.966V21.893C4,22.227 4.167,22.562 4.501,22.77L7.885,24.734C8.178,24.901 8.554,24.901 8.888,24.734L11.186,23.397L12.738,22.498C12.328,22.136 11.939,21.751 11.574,21.344L11.186,21.559L8.888,22.937C8.595,23.105 8.219,23.105 7.885,22.937L6.089,21.851C5.796,21.684 5.587,21.35 5.587,20.974V18.885C5.587,18.551 5.754,18.217 6.089,18.008L7.885,16.964C8.178,16.796 8.554,16.796 8.888,16.964L8.911,16.978C8.668,16.341 8.472,15.683 8.324,15.007ZM20,25.858C19.423,25.776 18.857,25.658 18.305,25.507C18.228,25.711 18.083,25.895 17.87,26.029L16.115,27.073C15.822,27.24 15.446,27.24 15.112,27.073L13.316,26.029C13.023,25.862 12.814,25.528 12.814,25.152V23.815L11.227,24.734V26.112C11.227,26.447 11.394,26.781 11.729,26.99L15.112,28.953C15.211,29.01 15.319,29.047 15.432,29.065C15.654,29.101 15.894,29.064 16.115,28.953L19.499,26.99C19.791,26.823 20,26.488 20,26.112V25.858Z"
android:fillColor="#FEFEFD"
android:fillType="evenOdd"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,55 @@
package com.tangem.data.settings
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_SHOW_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_SHOW_KEY
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.settings.repositories.SwapPromoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Calendar
/**
* Repository for showing swap promo notification.
*/
class DefaultSwapPromoRepository(
private val appPreferencesStore: AppPreferencesStore,
) : SwapPromoRepository {
override fun isReadyToShowWallet(): Flow<Boolean> {
return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_SHOW_KEY, true)
.map { it && checkPromoPeriod() }
}
override fun isReadyToShowToken(): Flow<Boolean> {
return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_SHOW_KEY, true)
.map { it && checkPromoPeriod() }
}
override suspend fun setNeverToShowWallet() {
appPreferencesStore.store(
key = IS_WALLET_SWAP_PROMO_SHOW_KEY,
value = false,
)
}
override suspend fun setNeverToShowToken() {
appPreferencesStore.store(
key = IS_TOKEN_SWAP_PROMO_SHOW_KEY,
value = false,
)
}
private fun checkPromoPeriod(): Boolean {
val currentDate = Calendar.getInstance()
return currentDate.get(Calendar.DATE) in START_DATE_KEY..END_DATE_KEY &&
currentDate.get(Calendar.MONTH) == MONTH_KEY && currentDate.get(Calendar.YEAR) == YEAR_KEY
}
companion object {
private const val START_DATE_KEY = 15
private const val END_DATE_KEY = 31
private const val MONTH_KEY = 11 // December
private const val YEAR_KEY = 2023 // just in case
}
}

View file

@ -2,10 +2,12 @@ package com.tangem.data.settings.di
import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.settings.DefaultSwapPromoRepository
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -36,4 +38,10 @@ internal object SettingsDataModule {
fun provideAppRatingRepository(appPreferencesStore: AppPreferencesStore): AppRatingRepository {
return DefaultAppRatingRepository(appPreferencesStore = appPreferencesStore)
}
@Provides
@Singleton
fun provideSwapPromoRepository(appPreferencesStore: AppPreferencesStore): SwapPromoRepository {
return DefaultSwapPromoRepository(appPreferencesStore = appPreferencesStore)
}
}

View file

@ -38,7 +38,7 @@ import timber.log.Timber
import java.math.BigDecimal
import java.util.EnumSet
@Suppress("LargeClass")
@Suppress("LargeClass", "TooManyFunctions")
// FIXME: Move to its own module and make internal
@Deprecated("Inject the WalletManagerFacade interface using DI instead")
class DefaultWalletManagersFacade(
@ -433,6 +433,20 @@ class DefaultWalletManagersFacade(
)
}
override suspend fun estimateFee(
amount: Amount,
userWalletId: UserWalletId,
network: Network,
): Result<TransactionFee>? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
return (walletManager as? TransactionSender)?.estimateFee(amount = amount)
}
override suspend fun validateTransaction(
amount: Amount,
fee: Amount?,

View file

@ -25,6 +25,7 @@ import java.util.EnumSet
/**
* A facade for managing wallets.
*/
@Suppress("TooManyFunctions")
interface WalletManagersFacade {
/**
@ -166,6 +167,15 @@ interface WalletManagersFacade {
network: Network,
): Result<TransactionFee>?
/**
* Returns estimated fee for transaction
*
* @param amount of transaction
* @param userWalletId selected wallet id
* @param network network of currency
*/
suspend fun estimateFee(amount: Amount, userWalletId: UserWalletId, network: Network): Result<TransactionFee>?
/**
* Validates transaction
*

View file

@ -0,0 +1,11 @@
package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SwapPromoRepository
import kotlinx.coroutines.flow.Flow
class ShouldShowSwapPromoTokenUseCase(private val swapPromoRepository: SwapPromoRepository) {
operator fun invoke(): Flow<Boolean> = swapPromoRepository.isReadyToShowToken()
suspend fun neverToShow() = swapPromoRepository.setNeverToShowToken()
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SwapPromoRepository
import kotlinx.coroutines.flow.Flow
class ShouldShowSwapPromoWalletUseCase(private val swapPromoRepository: SwapPromoRepository) {
operator fun invoke(): Flow<Boolean> = swapPromoRepository.isReadyToShowWallet()
suspend fun neverToShow() = swapPromoRepository.setNeverToShowWallet()
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.settings.repositories
import kotlinx.coroutines.flow.Flow
interface SwapPromoRepository {
fun isReadyToShowWallet(): Flow<Boolean>
fun isReadyToShowToken(): Flow<Boolean>
suspend fun setNeverToShowWallet()
suspend fun setNeverToShowToken()
}

View file

@ -18,6 +18,9 @@ dependencies {
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.settings)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
/** Project - Other */
implementation(projects.core.utils)

View file

@ -31,4 +31,6 @@ sealed class CryptoCurrencyWarning {
data class Rent(val rent: BigDecimal, val exemptionAmount: BigDecimal) : CryptoCurrencyWarning()
data class HasPendingTransactions(val blockchainSymbol: String) : CryptoCurrencyWarning()
object SwapPromo : CryptoCurrencyWarning()
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.tokens.models.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
sealed class TokenSwapPromoAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Swap Promo", event, params, null) {
object Close : TokenSwapPromoAnalyticsEvent(event = "Button - Close")
class Exchange(
token: String,
) : TokenSwapPromoAnalyticsEvent(
event = "Button - Exchange Now",
params = mapOf("Token" to token),
)
}

View file

@ -1,24 +1,33 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
@Suppress("LongParameterList")
class GetCurrencyWarningsUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val currenciesRepository: CurrenciesRepository,
private val quotesRepository: QuotesRepository,
private val networksRepository: NetworksRepository,
private val swapRepository: SwapRepository,
private val marketCryptoCurrencyRepository: MarketCryptoCurrencyRepository,
private val showSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
private val dispatchers: CoroutineDispatcherProvider,
) {
@ -29,20 +38,32 @@ class GetCurrencyWarningsUseCase(
isSingleWalletWithTokens: Boolean,
): Flow<Set<CryptoCurrencyWarning>> {
val currency = currencyStatus.currency
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
userWalletId = userWalletId,
)
return combine(
getCoinRelatedWarnings(
userWalletId = userWalletId,
operations = operations,
networkId = currency.network.id,
currencyId = currency.id,
derivationPath = derivationPath,
isSingleWalletWithTokens = isSingleWalletWithTokens,
),
operations.getCurrenciesStatusesFlow().conflate(),
flowOf(walletManagersFacade.getRentInfo(userWalletId, currency.network)),
flowOf(walletManagersFacade.getExistentialDeposit(userWalletId, currency.network)),
flowOf(getNetworkUnavailableWarning(currencyStatus)),
flowOf(getNetworkNoAccountWarning(currencyStatus)),
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeNetworkUnavailable, maybeNetworkNoAccount ->
showSwapPromoTokenUseCase().conflate(),
) { coinRelatedWarnings, cryptoStatuses, maybeRentWarning, maybeEdWarning, shouldShowSwapPromo ->
setOfNotNull(
getSwapPromoNotificationWarning(
shouldShowSwapPromo = shouldShowSwapPromo,
userWalletId = userWalletId,
currencyStatus = currencyStatus,
cryptoStatuses = cryptoStatuses,
),
maybeRentWarning,
maybeEdWarning?.let {
CryptoCurrencyWarning.ExistentialDeposit(
@ -51,27 +72,69 @@ class GetCurrencyWarningsUseCase(
)
},
*coinRelatedWarnings.toTypedArray(),
maybeNetworkUnavailable,
maybeNetworkNoAccount,
getNetworkUnavailableWarning(currencyStatus),
getNetworkNoAccountWarning(currencyStatus),
)
}.flowOn(dispatchers.io)
}
private suspend fun getSwapPromoNotificationWarning(
shouldShowSwapPromo: Boolean,
userWalletId: UserWalletId,
currencyStatus: CryptoCurrencyStatus,
cryptoStatuses: Either<CurrenciesStatusesOperations.Error, List<CryptoCurrencyStatus>>,
): CryptoCurrencyWarning? {
val currency = currencyStatus.currency
return if (shouldShowSwapPromo && marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)) {
cryptoStatuses.fold(
ifLeft = { null },
ifRight = { cryptoCurrencyStatuses ->
val pairs = swapRepository.getPairs(
LeastTokenInfo(
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",
network = currency.network.backendId,
),
cryptoCurrencyStatuses.map { it.currency },
)
val filteredCurrencies = cryptoCurrencyStatuses.filterNot {
it.currency.network.backendId == currency.network.backendId
}
val currencyPairs = pairs.pairs.filter {
it.from.network == currency.network.backendId ||
it.to.network == currency.network.backendId
}
val isExchangeable = currencyPairs.any { pair ->
val availablePair = if (currencyStatus.value.amount.isZero()) {
filteredCurrencies.filterNot { it.value.amount.isZero() }
} else {
filteredCurrencies
}
availablePair
.any {
it.currency.network.backendId == pair.to.network ||
it.currency.network.backendId == pair.from.network
}
}
if (isExchangeable) {
CryptoCurrencyWarning.SwapPromo
} else {
null
}
},
)
} else {
null
}
}
@Suppress("LongParameterList")
private suspend fun getCoinRelatedWarnings(
userWalletId: UserWalletId,
operations: CurrenciesStatusesOperations,
networkId: Network.ID,
currencyId: CryptoCurrency.ID,
derivationPath: Network.DerivationPath,
isSingleWalletWithTokens: Boolean,
): Flow<List<CryptoCurrencyWarning>> {
val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
userWalletId = userWalletId,
)
val currencyFlow = if (isSingleWalletWithTokens) {
operations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId)
} else {

View file

@ -0,0 +1,63 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.math.BigDecimal
/**
* Use case to estimate transaction fee
*/
class EstimateFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val dispatcher: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
amount: BigDecimal,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Either<GetFeeError.DataError, TransactionFee>> {
return flow {
val result = walletManagersFacade.estimateFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
val maybeFee = when (result) {
is Result.Success -> result.data.right()
else -> GetFeeError.DataError.left()
}
emit(maybeFee)
}.flowOn(dispatcher.io)
}
private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = amount,
decimals = cryptoCurrency.decimals,
type = when (cryptoCurrency) {
is CryptoCurrency.Coin -> AmountType.Coin
is CryptoCurrency.Token -> AmountType.Token(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
},
)
}

View file

@ -15,6 +15,8 @@ dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.models)
implementation(projects.features.swap.domain.api)
/** Network */
implementation(deps.retrofit)

View file

@ -1,8 +1,10 @@
package com.tangem.feature.swap
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.blockchain.common.Amount
import com.tangem.blockchain.common.Approver
import com.tangem.blockchain.common.Blockchain
@ -27,12 +29,11 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.converters.*
import com.tangem.feature.swap.domain.SwapRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
@ -184,7 +185,7 @@ internal class SwapRepositoryImpl @Inject constructor(
toDecimals: Int,
providerId: String,
rateType: RateType,
): AggregatedSwapDataModel<QuoteModel> {
): Either<DataError, QuoteModel> {
return withContext(coroutineDispatcher.io) {
try {
val response = tangemExpressApi.getExchangeQuote(
@ -198,14 +199,12 @@ internal class SwapRepositoryImpl @Inject constructor(
providerId = providerId,
rateType = rateType.name.lowercase(),
).getOrThrow()
AggregatedSwapDataModel(
dataModel = QuoteModel(
toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals),
allowanceContract = response.allowanceContract,
),
)
QuoteModel(
toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals),
allowanceContract = response.allowanceContract,
).right()
} catch (ex: Exception) {
AggregatedSwapDataModel(null, getDataError(ex))
getDataError(ex).left()
}
}
}
@ -227,7 +226,7 @@ internal class SwapRepositoryImpl @Inject constructor(
providerId: String,
rateType: RateType,
toAddress: String,
): AggregatedSwapDataModel<SwapDataModel> {
): Either<DataError, SwapDataModel> {
return withContext(coroutineDispatcher.io) {
try {
val response = tangemExpressApi.getExchangeData(
@ -242,11 +241,9 @@ internal class SwapRepositoryImpl @Inject constructor(
rateType = rateType.name.lowercase(),
toAddress = toAddress,
).getOrThrow()
AggregatedSwapDataModel(
dataModel = expressDataConverter.convert(response),
)
expressDataConverter.convert(response).right()
} catch (ex: Exception) {
AggregatedSwapDataModel(null, getDataError(ex))
getDataError(ex).left()
}
}
}

View file

@ -13,7 +13,7 @@ import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.feature.swap.SwapRepositoryImpl
import com.tangem.feature.swap.converters.ErrorsDataConverter
import com.tangem.feature.swap.DefaultSwapTransactionRepository
import com.tangem.feature.swap.domain.SwapRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module

1
features/swap/domain/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,18 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.domain.api"
}
dependencies {
implementation(projects.features.swap.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(deps.arrow.core)
}

View file

@ -1,10 +1,10 @@
package com.tangem.feature.swap.domain
package com.tangem.feature.swap.domain.api
import arrow.core.Either
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.DataError
import java.math.BigDecimal
interface SwapRepository {
@ -28,7 +28,7 @@ interface SwapRepository {
toDecimals: Int,
providerId: String,
rateType: RateType,
): AggregatedSwapDataModel<QuoteModel>
): Either<DataError, QuoteModel>
/**
* Returns address of 1inch router that must be trusted
@ -77,7 +77,7 @@ interface SwapRepository {
providerId: String,
rateType: RateType,
toAddress: String,
): AggregatedSwapDataModel<SwapDataModel>
): Either<DataError, SwapDataModel>
fun getNativeTokenForNetwork(networkId: String): CryptoCurrency
}

View file

@ -29,6 +29,8 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.txhistory.models)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
/** Core modules */
implementation(projects.core.utils)
@ -38,7 +40,6 @@ dependencies {
implementation(projects.features.wallet.api)
/** Other Libraries **/
implementation(deps.kotlin.serialization)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.timber)

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,23 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.domain.models"
}
dependencies {
/** Domain */
implementation(projects.domain.tokens.models)
/** Core modules */
implementation(projects.core.utils)
/** Other Libraries **/
implementation(deps.kotlin.serialization)
implementation(deps.arrow.core)
}

View file

@ -5,7 +5,10 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
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.*
interface SwapInteractor {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.domain
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
@ -13,15 +14,16 @@ import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
@ -54,8 +56,8 @@ internal class SwapInteractorImpl @Inject constructor(
private val initialToCurrencyResolver: InitialToCurrencyResolver,
) : SwapInteractor {
private val getFeeUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetFeeUseCase(walletManagersFacade, dispatcher)
private val estimateFeeUseCase by lazy(LazyThreadSafetyMode.NONE) {
EstimateFeeUseCase(walletManagersFacade, dispatcher)
}
private val swapCurrencyConverter = SwapCurrencyConverter()
@ -297,13 +299,14 @@ internal class SwapInteractorImpl @Inject constructor(
)
val fromTokenAddress = getTokenAddress(fromToken.currency)
val isAllowedToSpend = if (quotes.dataModel != null) {
quotes.dataModel.allowanceContract?.let {
isAllowedToSpend(networkId, fromToken.currency, amount, it)
} ?: true
} else {
false
}
val isAllowedToSpend = quotes.fold(
ifRight = {
it.allowanceContract?.let {
isAllowedToSpend(networkId, fromToken.currency, amount, it)
} ?: true
},
ifLeft = { false },
)
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
@ -497,13 +500,13 @@ internal class SwapInteractorImpl @Inject constructor(
providerId = swapProvider.providerId,
rateType = RateType.FLOAT,
toAddress = currencyToGet.value.networkAddress?.defaultAddress?.value ?: "",
)
).getOrNull()
val txData = walletManagersFacade.createTransaction(
amount = amount.value.convertToAmount(currencyToSend.currency),
fee = getFeeForTransaction(txFee),
memo = null,
destination = (exchangeData.dataModel?.transaction as ExpressTransactionModel.CEX).txTo,
destination = (exchangeData?.transaction as ExpressTransactionModel.CEX).txTo,
userWalletId = userWalletId,
network = currencyToSend.currency.network,
)
@ -514,7 +517,7 @@ internal class SwapInteractorImpl @Inject constructor(
network = currencyToSend.currency.network,
)
val externalUrl = (exchangeData.dataModel.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl
val externalUrl = (exchangeData.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl
return result.fold(
ifLeft = {
@ -533,7 +536,7 @@ internal class SwapInteractorImpl @Inject constructor(
currencyToGet = currencyToGet,
amount = amount,
swapProvider = swapProvider,
swapDataModel = exchangeData.dataModel,
swapDataModel = exchangeData,
timestamp = timestamp,
)
storeLastCryptoCurrencyId(currencyToGet.currency)
@ -544,10 +547,10 @@ internal class SwapInteractorImpl @Inject constructor(
),
fromAmountValue = amount.value,
toAmount = amountFormatter.formatSwapAmountToUI(
exchangeData.dataModel.toTokenAmount,
exchangeData.toTokenAmount,
currencyToGet.currency.symbol,
),
toAmountValue = exchangeData.dataModel.toTokenAmount.value,
toAmountValue = exchangeData.toTokenAmount.value,
txAddress = userWalletManager.getLastTransactionHash(
currencyToSend.currency.network.backendId,
derivationPath,
@ -751,7 +754,7 @@ internal class SwapInteractorImpl @Inject constructor(
private suspend fun getQuotesState(
exchangeProviderType: ExchangeProviderType,
quoteDataModel: AggregatedSwapDataModel<QuoteModel>,
quoteDataModel: Either<DataError, QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
@ -761,58 +764,60 @@ internal class SwapInteractorImpl @Inject constructor(
txFee: TxFeeState,
includeFeeInAmount: IncludeFeeInAmount,
): SwapState {
val quoteModel = quoteDataModel.dataModel
if (quoteModel != null) {
val swapState = updateBalances(
networkId = networkId,
fromTokenStatus = fromToken,
toTokenStatus = toToken,
fromTokenAmount = amount,
toTokenAmount = quoteModel.toTokenAmount,
swapData = null,
txFeeState = txFee,
)
return quoteDataModel.fold(
ifRight = { quoteModel ->
val swapState = updateBalances(
networkId = networkId,
fromTokenStatus = fromToken,
toTokenStatus = toToken,
fromTokenAmount = amount,
toTokenAmount = quoteModel.toTokenAmount,
swapData = null,
txFeeState = txFee,
)
return when (exchangeProviderType) {
ExchangeProviderType.DEX -> {
val state = updatePermissionState(
networkId = networkId,
fromTokenStatus = fromToken,
swapAmount = amount,
quotesLoadedState = swapState,
isAllowedToSpend = isAllowedToSpend,
spenderAddress = quoteModel.allowanceContract,
)
state.copy(
preparedSwapConfigState = state.preparedSwapConfigState.copy(
when (exchangeProviderType) {
ExchangeProviderType.DEX -> {
val state = updatePermissionState(
networkId = networkId,
fromTokenStatus = fromToken,
swapAmount = amount,
quotesLoadedState = swapState,
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
),
)
spenderAddress = quoteModel.allowanceContract,
)
state.copy(
preparedSwapConfigState = state.preparedSwapConfigState.copy(
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
),
)
}
ExchangeProviderType.CEX -> {
swapState.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
includeFeeInAmount = includeFeeInAmount,
),
)
}
}
ExchangeProviderType.CEX -> {
swapState.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
includeFeeInAmount = includeFeeInAmount,
),
)
}
}
} else {
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(fromTokenSwapInfo, quoteDataModel.error)
}
},
ifLeft = { error ->
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(fromTokenSwapInfo, error)
},
)
}
private suspend fun getIncludeFeeInAmount(
@ -882,7 +887,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
selectedFee: FeeType,
): SwapState {
repository.getExchangeData(
return repository.getExchangeData(
fromContractAddress = fromToken.currency.getContractAddress(),
fromNetwork = fromToken.currency.network.backendId,
toContractAddress = toToken.currency.getContractAddress(),
@ -893,9 +898,8 @@ internal class SwapInteractorImpl @Inject constructor(
providerId = provider.providerId,
rateType = RateType.FLOAT,
toAddress = toToken.value.networkAddress?.defaultAddress?.value ?: "",
).let {
val swapData = it.dataModel
if (swapData != null) {
).fold(
ifRight = { swapData ->
val feeData = transactionManager.getFee(
networkId = networkId,
amountToSend = amount.value,
@ -926,7 +930,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
txFeeState = txFeeState,
)
return swapState.copy(
swapState.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = true,
@ -936,7 +940,8 @@ internal class SwapInteractorImpl @Inject constructor(
includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex
),
)
} else {
},
ifLeft = { error ->
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
@ -944,12 +949,12 @@ internal class SwapInteractorImpl @Inject constructor(
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(
SwapState.SwapError(
fromTokenSwapInfo,
it.error,
error,
)
}
}
},
)
}
@Suppress("LongParameterList")
@ -999,9 +1004,8 @@ internal class SwapInteractorImpl @Inject constructor(
networkId: String,
): TxFeeState {
getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId ->
val txFeeResult = getFeeUseCase(
val txFeeResult = estimateFeeUseCase(
amount = amount.value,
destination = fromToken.value.networkAddress?.defaultAddress?.value ?: "",
userWalletId = userWalletId,
cryptoCurrency = fromToken.currency,
).firstOrNull()

View file

@ -13,6 +13,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.lib.crypto.TransactionManager
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -1,15 +0,0 @@
package com.tangem.feature.swap.domain.models.data
import com.tangem.feature.swap.domain.models.DataError
/**
* Model that aggregate data model from repository return with error [DataError] if it exists
*
* @param T model type
* @property dataModel
* @property error possible from repository [DataError]
*/
data class AggregatedSwapDataModel<T>(
val dataModel: T?,
val error: DataError = DataError.UnknownError,
)

View file

@ -26,6 +26,10 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
/** AndroidX */
implementation(deps.androidx.activity.compose)
@ -48,11 +52,6 @@ dependencies {
implementation(projects.features.swap.api)
implementation(projects.features.tokendetails.api)
/** Domain */
implementation(projects.features.swap.domain)
implementation(projects.domain.tokens.models)
implementation(projects.domain.settings)
/** Other libraries */
implementation(deps.compose.shimmer)
implementation(deps.kotlin.serialization)

View file

@ -651,9 +651,17 @@ internal class StateBuilder(
}
fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder {
val warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded }.toMutableList()
warnings.add(
0,
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.swapping_pending_transaction_title),
description = resourceReference(R.string.swapping_pending_transaction_subtitle),
),
)
return uiState.copy(
permissionState = SwapPermissionState.InProgress,
warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded },
warnings = warnings,
)
}

View file

@ -22,8 +22,8 @@ import com.tangem.feature.swap.domain.models.ExpressException
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.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.feature.swap.router.SwapNavScreen
@ -560,6 +560,7 @@ internal class SwapViewModel @Inject constructor(
is TxState.TxSent -> {
uiState = stateBuilder.loadingPermissionState(uiState)
uiState = stateBuilder.dismissBottomSheet(uiState)
startLoadingQuotesFromLastState(isSilent = true)
}
is TxState.UserCancelled -> Unit
else -> {

View file

@ -70,9 +70,10 @@ dependencies {
/** Temp dependency to swap domain */
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
/** Feature Apis */
implementation(projects.features.tokendetails.api)
implementation(projects.features.send.api)
implementation(projects.features.swap.domain)
}

View file

@ -1,12 +1,13 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.CryptoCurrency
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.ExchangeStatusNotifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.collections.immutable.ImmutableList
internal data class SwapTransactionsState(
val txId: String,
@ -14,10 +15,10 @@ internal data class SwapTransactionsState(
val txUrl: String? = null,
val timestamp: TextReference,
val fiatSymbol: String,
val activeStatus: MutableStateFlow<ExchangeStatus?>,
val hasFailed: MutableStateFlow<Boolean>,
val statuses: MutableStateFlow<List<ExchangeStatusState>>,
val notification: MutableStateFlow<ExchangeStatusNotifications?> = MutableStateFlow(null),
val activeStatus: ExchangeStatus?,
val hasFailed: Boolean,
val statuses: ImmutableList<ExchangeStatusState>,
val notification: ExchangeStatusNotifications? = null,
val toCryptoCurrencyId: CryptoCurrency.ID,
val toCryptoAmount: String,
val toCryptoSymbol: String,
@ -32,6 +33,7 @@ internal data class SwapTransactionsState(
val onGoToProviderClick: (String) -> Unit,
)
@Immutable
internal class ExchangeStatusState(
val status: ExchangeStatus,
val text: TextReference,

View file

@ -39,6 +39,23 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
data class SwapPromo(
val onSwapClick: () -> Unit,
val onCloseClick: () -> Unit,
) : TokenDetailsNotification(
config = NotificationConfig(
title = resourceReference(id = R.string.token_swap_promotion_title),
subtitle = resourceReference(id = R.string.token_swap_promotion_message),
iconResId = R.drawable.ic_swap_promo_34,
backgroundResId = R.drawable.img_swap_promo_banner_background,
onCloseClick = onCloseClick,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = com.tangem.core.ui.R.string.token_swap_promotion_button),
onClick = onSwapClick,
),
),
)
object NetworksUnreachable : Warning(
title = resourceReference(R.string.warning_network_unreachable_title),
subtitle = resourceReference(R.string.warning_network_unreachable_message),

View file

@ -45,6 +45,10 @@ internal class TokenDetailsNotificationConverter(
is CryptoCurrencyWarning.HasPendingTransactions -> TokenDetailsNotification.HasPendingTransactions(
coinSymbol = warning.blockchainSymbol,
)
is CryptoCurrencyWarning.SwapPromo -> TokenDetailsNotification.SwapPromo(
onSwapClick = clickIntents::onSwapPromoClick,
onCloseClick = clickIntents::onSwapPromoDismiss,
)
}
}
}

View file

@ -34,6 +34,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@Suppress("TooManyFunctions")
internal class TokenDetailsStateFactory(
private val currentStateProvider: Provider<TokenDetailsState>,
private val appCurrencyProvider: Provider<AppCurrency>,
@ -247,6 +248,19 @@ internal class TokenDetailsStateFactory(
)
}
fun updateStateWithExchangeStatusBottomSheet(swapTxState: SwapTransactionsState): TangemBottomSheetConfig? {
val state = currentStateProvider()
val bottomSheetConfig = state.bottomSheetConfig
val currentConfig = bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig ?: return bottomSheetConfig
return bottomSheetConfig.copy(
content = if (currentConfig.value != swapTxState) {
ExchangeStatusBottomSheetConfig(swapTxState)
} else {
currentConfig
},
)
}
fun getStateAndTriggerEvent(
state: TokenDetailsState,
errorMessage: TextReference,

View file

@ -20,11 +20,10 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.component
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.math.BigDecimal
internal class TokenDetailsSwapTransactionsStateConverter(
@ -70,14 +69,12 @@ internal class TokenDetailsSwapTransactionsStateConverter(
txUrl = transaction.status?.txUrl,
timestamp = TextReference.Str("${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}"),
fiatSymbol = appCurrency.symbol,
statuses = MutableStateFlow(getStatuses(transaction.status?.status)),
hasFailed = MutableStateFlow(transaction.status?.status == ExchangeStatus.Failed),
activeStatus = MutableStateFlow(transaction.status?.status),
notification = MutableStateFlow(
getNotification(
transaction.status?.status,
transaction.status?.txUrl,
),
statuses = getStatuses(transaction.status?.status),
hasFailed = transaction.status?.status == ExchangeStatus.Failed,
activeStatus = transaction.status?.status,
notification = getNotification(
transaction.status?.status,
transaction.status?.txUrl,
),
toCryptoCurrencyId = toCurrency.currency.id,
toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(
@ -109,13 +106,16 @@ internal class TokenDetailsSwapTransactionsStateConverter(
return result.toPersistentList()
}
fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?) {
if (statusModel == null || tx.activeStatus.value == statusModel.status) return
val hasFailed = tx.hasFailed.value || statusModel.status == ExchangeStatus.Failed
tx.activeStatus.update { statusModel.status }
tx.hasFailed.update { hasFailed }
tx.notification.update { getNotification(statusModel.status, statusModel.txUrl) }
tx.statuses.update { getStatuses(statusModel.status, hasFailed) }
fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?): SwapTransactionsState {
if (statusModel == null || tx.activeStatus == statusModel.status) return tx
val hasFailed = tx.hasFailed || statusModel.status == ExchangeStatus.Failed
return tx.copy(
activeStatus = statusModel.status,
hasFailed = hasFailed,
notification = getNotification(statusModel.status, statusModel.txUrl),
statuses = getStatuses(statusModel.status, hasFailed),
txUrl = statusModel.txUrl,
)
}
private fun getFiatAmount(toFiatAmount: BigDecimal): String {
@ -149,8 +149,8 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}
}
private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): List<ExchangeStatusState> {
if (status == null) return emptyList()
private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): ImmutableList<ExchangeStatusState> {
if (status == null) return persistentListOf()
val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting
val isConfirming = status == ExchangeStatus.Confirming
val isVerifying = status == ExchangeStatus.Verifying
@ -181,7 +181,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
isRefunded = isRefunded,
hasFailed = hasFailed,
),
)
).toPersistentList()
}
private fun waitStep(isNew: Boolean, isNewDone: Boolean) = ExchangeStatusState(

View file

@ -28,6 +28,7 @@ import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBotto
import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.event.EventEffect
@ -104,14 +105,21 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
key = { it::class.java },
contentType = { it.config::class.java },
itemContent = {
Notification(
modifier = itemModifier.animateItemPlacement(),
config = it.config,
iconTint = when (it) {
is TokenDetailsNotification.Warning -> null
is TokenDetailsNotification.Informational -> TangemTheme.colors.icon.accent
},
)
if (it is TokenDetailsNotification.SwapPromo) {
NotificationWithBackground(
config = it.config,
modifier = itemModifier.animateItemPlacement(),
)
} else {
Notification(
modifier = itemModifier.animateItemPlacement(),
config = it.config,
iconTint = when (it) {
is TokenDetailsNotification.Informational -> TangemTheme.colors.icon.accent
else -> null
},
)
}
},
)
if (state.isMarketPriceAvailable) {

View file

@ -18,23 +18,21 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ExchangeStatusState
import com.tangem.features.tokendetails.impl.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun ExchangeStatusBlock(
statuses: MutableStateFlow<List<ExchangeStatusState>>,
statuses: ImmutableList<ExchangeStatusState>,
showLink: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusValues = statuses.collectAsStateWithLifecycle()
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
@ -77,10 +75,10 @@ internal fun ExchangeStatusBlock(
}
}
statusValues.value.forEachIndexed { index, item ->
statuses.forEachIndexed { index, item ->
ExchangeStatusStep(
stepStatus = item,
isLast = index == statusValues.value.lastIndex,
isLast = index == statuses.lastIndex,
)
}
}

View file

@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH10
import com.tangem.core.ui.components.SpacerH12
@ -36,8 +35,6 @@ internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) {
@Composable
private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetConfig) {
val config = content.value
val status = config.activeStatus.collectAsStateWithLifecycle()
val notification = config.notification.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
@ -77,15 +74,15 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC
SpacerH12()
ExchangeStatusBlock(
statuses = config.statuses,
showLink = notification.value == null && config.txUrl != null,
showLink = config.notification == null && config.txUrl != null,
onClick = { config.onGoToProviderClick(config.txUrl.orEmpty()) },
)
AnimatedContent(
targetState = notification.value,
targetState = config.notification,
label = "Exchange Status Notification Change",
) {
it?.let {
val tint = when (status.value) {
val tint = when (config.activeStatus) {
ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention
ExchangeStatus.Failed -> TangemTheme.colors.icon.warning
else -> null

View file

@ -18,7 +18,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.constraintlayout.compose.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.tokenicon.TokenIcon
@ -41,8 +40,7 @@ internal fun LazyListScope.swapTransactionsItems(
contentType = { swapTxs[it]::class.java },
) {
val item = swapTxs[it]
val status = item.activeStatus.collectAsStateWithLifecycle()
val (iconRes, tint) = when (status.value) {
val (iconRes, tint) = when (item.activeStatus) {
ExchangeStatus.Verifying -> R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention
ExchangeStatus.Failed -> R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning
else -> null to null

View file

@ -12,8 +12,8 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.SwapRepository
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel
@ -81,8 +81,9 @@ internal class ExchangeStatusFactory(
swapTxList.map { tx ->
async {
val statusModel = getExchangeStatus(tx.txId)
swapTransactionsStateConverter.updateTxStatus(tx, statusModel)
tx.removeIfFinished(statusModel?.status)
swapTransactionsStateConverter
.updateTxStatus(tx, statusModel)
.removeIfFinished(statusModel?.status)
}
}
.awaitAll()

View file

@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels
import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel
import com.tangem.domain.tokens.model.CryptoCurrency
@Suppress("TooManyFunctions")
interface TokenDetailsClickIntents {
fun onBackClick()
@ -42,4 +43,8 @@ interface TokenDetailsClickIntents {
fun onSwapTransactionClick(txId: String)
fun onGoToProviderClick(url: String)
fun onSwapPromoDismiss()
fun onSwapPromoClick()
}

View file

@ -20,6 +20,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.CryptoCurrency
@ -28,6 +29,7 @@ import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
@ -35,12 +37,13 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapRepository
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter
import com.tangem.utils.coroutines.*
@ -74,6 +77,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase,
private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
private val swapRepository: SwapRepository,
private val swapTransactionRepository: SwapTransactionRepository,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
@ -226,7 +230,14 @@ internal class TokenDetailsViewModel @Inject constructor(
}
},
onSuccess = { updatedTxs ->
uiState = uiState.copy(swapTxs = updatedTxs)
val config = uiState.bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig
val currentTx = updatedTxs.firstOrNull { it.txId == config?.value?.txId }
uiState = uiState.copy(
swapTxs = updatedTxs,
bottomSheetConfig = currentTx?.let(
stateFactory::updateStateWithExchangeStatusBottomSheet,
),
)
},
onError = {},
),
@ -547,6 +558,18 @@ internal class TokenDetailsViewModel @Inject constructor(
router.openUrl(url)
}
override fun onSwapPromoDismiss() {
viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoTokenUseCase.neverToShow()
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Close)
}
}
override fun onSwapPromoClick() {
onSwapClick()
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Exchange(cryptoCurrency.symbol))
}
private companion object {
const val EXCHANGE_STATUS_UPDATE_DELAY = 10_000L
}

View file

@ -166,4 +166,16 @@ sealed class WalletNotification(val config: NotificationConfig) {
onCloseClick = onCloseClick,
),
)
data class SwapPromo(
val onCloseClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = resourceReference(id = R.string.main_swap_promotion_title),
subtitle = resourceReference(id = R.string.main_swap_promotion_message),
iconResId = R.drawable.ic_swap_promo_34,
backgroundResId = R.drawable.img_swap_promo_banner_background,
onCloseClick = onCloseClick,
),
)
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification
import kotlinx.collections.immutable.ImmutableList
@ -24,17 +25,24 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
key = { it::class.java },
contentType = { it::class.java },
itemContent = {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
iconTint = when (it) {
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.Warning -> null
},
)
if (it is WalletNotification.SwapPromo) {
NotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
} else {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
iconTint = when (it) {
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
else -> null
},
)
}
},
)
}

View file

@ -72,4 +72,6 @@ internal interface WalletClickIntents {
fun onExploreClick()
fun onTransactionClick(txHash: String)
fun onCloseSwapPromoNotificationClick()
}

View file

@ -5,6 +5,7 @@ import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.tokens.GetMissedAddressesCryptoCurrenciesUseCase
import com.tangem.domain.tokens.error.GetCurrenciesError
import com.tangem.domain.tokens.model.CryptoCurrency
@ -26,16 +27,21 @@ import kotlinx.coroutines.flow.flowOf
* @property isDemoCardUseCase use case that checks if card is demo
* @property isReadyToShowRateAppUseCase use case that checks if card is user already rate app
* @property isNeedToBackupUseCase use case that checks if wallet need backup cards
* @property getMissedAddressCryptoCurrenciesUseCase use case that gets missed address crypto currencies
* @property hasSingleWalletSignedHashesUseCase use case that checks if single wallet signed hashes
* @property shouldShowSwapPromoWalletUseCase use case that checks if should show swap promo
* @property clickIntents screen click intents
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class WalletNotificationsListFactory(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val getMissedAddressCryptoCurrenciesUseCase: GetMissedAddressesCryptoCurrenciesUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val clickIntents: WalletClickIntents,
) {
@ -51,9 +57,12 @@ internal class WalletNotificationsListFactory(
flow2 = isReadyToShowRateAppUseCase().conflate(),
flow3 = isNeedToBackupUseCase(selectedWallet.walletId).conflate(),
flow4 = getMissedAddressCryptoCurrenciesUseCase(selectedWallet.walletId).conflate(),
) { hasSignedHashes, isReadyToShowRating, isNeedToBackup, maybeMissedAddressCurrencies ->
flow5 = shouldShowSwapPromoWalletUseCase().conflate(),
) { hasSignedHashes, isReadyToShowRating, isNeedToBackup, maybeMissedAddressCurrencies, isShowSwapPromo ->
readyForRateAppNotification = true
buildList {
addSwapPromoNotification(isShowSwapPromo, cardTypesResolver)
addCriticalNotifications(cardTypesResolver)
addInformationalNotifications(cardTypesResolver, maybeMissedAddressCurrencies)
@ -77,6 +86,18 @@ internal class WalletNotificationsListFactory(
}
}
private fun MutableList<WalletNotification>.addSwapPromoNotification(
showSwapPromo: Boolean,
cardTypesResolver: CardTypesResolver,
) {
addIf(
element = WalletNotification.SwapPromo(
clickIntents::onCloseSwapPromoNotificationClick,
),
condition = showSwapPromo && cardTypesResolver.isMultiwalletAllowed(),
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(cardTypesResolver: CardTypesResolver) {
addIf(
element = WalletNotification.Critical.DevCard,

View file

@ -128,6 +128,7 @@ internal class WalletViewModel @Inject constructor(
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val scanCardToUnlockWalletUseCase: ScanCardToUnlockWalletClickHandler,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
isNeedToBackupUseCase: IsNeedToBackupUseCase,
getMissedAddressesCryptoCurrenciesUseCase: GetMissedAddressesCryptoCurrenciesUseCase,
@ -146,6 +147,7 @@ internal class WalletViewModel @Inject constructor(
isNeedToBackupUseCase = isNeedToBackupUseCase,
getMissedAddressCryptoCurrenciesUseCase = getMissedAddressesCryptoCurrenciesUseCase,
hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase,
shouldShowSwapPromoWalletUseCase = shouldShowSwapPromoWalletUseCase,
clickIntents = this,
)
@ -774,6 +776,12 @@ internal class WalletViewModel @Inject constructor(
refreshSingleCurrencyContent(selectedWalletIndex)
}
override fun onCloseSwapPromoNotificationClick() {
viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoWalletUseCase.neverToShow()
}
}
// FIXME: refreshSingleCurrencyContent mustn't update the TxHistory and Buttons. It only must fetch primary
// currency. Now it not works because GetPrimaryCurrency's subscriber uses .distinctUntilChanged()
private fun refreshSingleCurrencyContent(walletIndex: Int) {

View file

@ -88,7 +88,7 @@ spr-client = "3.6.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.4-416"
tangemBlockchainSdk = "release-app_5.4-417"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.4-315"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -79,12 +79,16 @@ include(":libs:auth")
// region Feature modules
include(":features:onboarding")
include(":features:referral:data")
include(":features:referral:domain")
include(":features:referral:presentation")
include(":features:swap:api")
include(":features:swap:data")
include(":features:swap:domain")
include(":features:swap:domain:models")
include(":features:swap:domain:api")
include(":features:swap:presentation")
include(":features:tester:api")