Updated on 2026-08-14
This commit is contained in:
commit
8c5c799536
24 changed files with 438 additions and 81 deletions
|
|
@ -17,6 +17,12 @@ data class ExchangeProvider(
|
|||
|
||||
@Json(name = "imageSmall")
|
||||
val imageSmallUrl: String,
|
||||
|
||||
@Json(name = "termsOfUse")
|
||||
val termsOfUse: String?,
|
||||
|
||||
@Json(name = "privacyPolicy")
|
||||
val privacyPolicy: String?,
|
||||
)
|
||||
|
||||
enum class ExchangeProviderType {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class AppPreferencesStore(
|
|||
return edit { transform(it) }
|
||||
}
|
||||
|
||||
/** Get data [T] by [key]. If data is not found, it returns [default] */
|
||||
inline fun <reified T> MutablePreferences.getOrDefault(key: Preferences.Key<T>, default: T): T {
|
||||
return this[key] ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nullable data [T] by string [key] from [MutablePreferences]
|
||||
*
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ object PreferencesKeys {
|
|||
|
||||
val IS_WALLET_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isWalletSwapPromoShown") }
|
||||
|
||||
val IS_TOKEN_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isTokenSwapPromoShown") }
|
||||
val IS_TOKEN_SWAP_PROMO_SHOW_KEY by lazy { stringSetPreferencesKey(name = "isTokenSwapPromoShown") }
|
||||
}
|
||||
|
||||
/** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore<Preferences> */
|
||||
|
|
|
|||
|
|
@ -197,17 +197,40 @@
|
|||
<string name="express_cex_status_button_title">Статус</string>
|
||||
<string name="express_choose_providers_subtitle">Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами</string>
|
||||
<string name="express_choose_providers_title">Выберите провайдера</string>
|
||||
<string name="express_exchange_notification_failed_text">Чтобы узнать причину, посетите сайт провайдера</string>
|
||||
<string name="express_exchange_notification_failed_title">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
|
||||
<string name="express_estimated_amount">Курс обмена</string>
|
||||
<string name="express_exchange_notification_failed_text">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
|
||||
<string name="express_exchange_notification_failed_title">Операция не выполнена провайдером</string>
|
||||
<string name="express_exchange_notification_verification_text">Посетите сайт провайдера для проверки</string>
|
||||
<string name="express_exchange_notification_verification_title">Провайдер запрашивает прохождение верификации</string>
|
||||
<string name="express_exchange_status_confirmed">Подтверждено</string>
|
||||
<string name="express_exchange_status_confirming">Подтверждение</string>
|
||||
<string name="express_exchange_status_confirming_active">Подтверждение...</string>
|
||||
<string name="express_exchange_status_exchanged">Обменяно</string>
|
||||
<string name="express_exchange_status_exchanging">Обмен</string>
|
||||
<string name="express_exchange_status_exchanging_active">Обмен...</string>
|
||||
<string name="express_exchange_status_failed">Неудачно</string>
|
||||
<string name="express_exchange_status_received">Депозит получен</string>
|
||||
<string name="express_exchange_status_receiving">Ожидание депозита</string>
|
||||
<string name="express_exchange_status_receiving_active">Ожидаем пополнения...</string>
|
||||
<string name="express_exchange_status_refunded">Возвращено</string>
|
||||
<string name="express_exchange_status_sending">Отправляем </string>
|
||||
<string name="express_exchange_status_sending_active">Отправка средств...</string>
|
||||
<string name="express_exchange_status_sent">Отправлено</string>
|
||||
<string name="express_exchange_status_verified">Верифицировано</string>
|
||||
<string name="express_exchange_status_verifying">Требуется верификация</string>
|
||||
<string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string>
|
||||
<string name="express_fetch_best_rates">Получение наилучших курсов...</string>
|
||||
<string name="express_floating_rate">Плавающая ставка</string>
|
||||
<string name="express_go_to_provider">К провайдеру</string>
|
||||
<string name="express_legal_one_placeholder">Пользуясь сервисом вы соглашаетесь с %s</string>
|
||||
<string name="express_legal_two_placeholders">Пользуясь сервисом вы соглашаетесь с %1$s и %2$s</string>
|
||||
<string name="express_privacy_policy">Политикой конфиденциальности</string>
|
||||
<string name="express_provider">Провайдер</string>
|
||||
<string name="express_provider_best_rate">Лучший курс</string>
|
||||
<string name="express_provider_min_amount">Доступно с %s</string>
|
||||
<string name="express_provider_not_available">Недоступно для этой пары</string>
|
||||
<string name="express_provider_permission_needed">Требуется разрешение</string>
|
||||
<string name="express_terms_of_use">Условиями использования</string>
|
||||
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
|
||||
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
|
||||
<string name="feedback_preface_scan_failed">Скажите, пожалуйста, какая у вас карта?</string>
|
||||
|
|
@ -515,6 +538,8 @@
|
|||
<string name="swapping_permission_rows_your_wallet">Ваш кошелек</string>
|
||||
<string name="swapping_permission_subheader">Чтобы продолжить, вам нужно разрешить смарт-контракту 1inch использовать ваш %s</string>
|
||||
<string name="swapping_permission_unlimited">Безлимитно</string>
|
||||
<string name="swapping_success_from_title">Вы отправляете</string>
|
||||
<string name="swapping_success_to_title">Вы получите</string>
|
||||
<string name="swapping_success_view_explorer_button_title">Открыть в обозревателе</string>
|
||||
<string name="swapping_success_view_title">В процессе</string>
|
||||
<string name="swapping_swap_action">Обменять</string>
|
||||
|
|
@ -541,9 +566,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_button">Обменять</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>
|
||||
|
|
@ -656,6 +681,8 @@
|
|||
<string name="warning_express_active_transaction_title">У вас есть активная транзакция</string>
|
||||
<string name="warning_express_no_exchangeable_coins_description">У вас в списке нет монет доступных для обмена с %s</string>
|
||||
<string name="warning_express_no_exchangeable_coins_title">Нет доступных токенов для обмена</string>
|
||||
<string name="warning_express_not_enough_fee_for_token_tx_description">Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s</string>
|
||||
<string name="warning_express_not_enough_fee_for_token_tx_title">Невозможно покрыть комиссию %s</string>
|
||||
<string name="warning_express_refresh_required_title">Cервис временно недоступен</string>
|
||||
<string name="warning_express_too_minimal_amount_description">Пожалуйста, измените сумму для обмена</string>
|
||||
<string name="warning_express_too_minimal_amount_title">Сумма для обмена должна быть не менее %s</string>
|
||||
|
|
|
|||
|
|
@ -224,11 +224,15 @@
|
|||
<string name="express_fetch_best_rates">Fetching best rates...</string>
|
||||
<string name="express_floating_rate">Floating rate</string>
|
||||
<string name="express_go_to_provider">Go to provider</string>
|
||||
<string name="express_legal_one_placeholder">By using swap functionality, you agree with provider’s %s</string>
|
||||
<string name="express_legal_two_placeholders">By using swap functionality, you agree with provider’s %1$s and %2$s</string>
|
||||
<string name="express_privacy_policy">Privacy Policy</string>
|
||||
<string name="express_provider">Provider</string>
|
||||
<string name="express_provider_best_rate">Best rate</string>
|
||||
<string name="express_provider_min_amount">Available from %s</string>
|
||||
<string name="express_provider_not_available">Unavailable for this pair</string>
|
||||
<string name="express_provider_permission_needed">Permission Required</string>
|
||||
<string name="express_terms_of_use">Terms of Use</string>
|
||||
<string name="feedback_data_collection_message">The following information is optional. You can erase it if you don\'t want to share it.</string>
|
||||
<string name="feedback_preface_rate_negative">Tell us what functions you are missing, and we will try to help you.</string>
|
||||
<string name="feedback_preface_scan_failed">Please tell us what card do you have</string>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ class DefaultSwapPromoRepository(
|
|||
.map { it && checkPromoPeriod() }
|
||||
}
|
||||
|
||||
override fun isReadyToShowToken(): Flow<Boolean> {
|
||||
return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_SHOW_KEY, true)
|
||||
.map { it && checkPromoPeriod() }
|
||||
override fun isReadyToShowToken(userWalletId: String, currencyId: String): Flow<Boolean> {
|
||||
return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_SHOW_KEY, emptySet())
|
||||
.map { !it.contains(userWalletId + currencyId) && checkPromoPeriod() }
|
||||
}
|
||||
|
||||
override suspend fun setNeverToShowWallet() {
|
||||
|
|
@ -33,11 +33,14 @@ class DefaultSwapPromoRepository(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun setNeverToShowToken() {
|
||||
appPreferencesStore.store(
|
||||
key = IS_TOKEN_SWAP_PROMO_SHOW_KEY,
|
||||
value = false,
|
||||
)
|
||||
override suspend fun setNeverToShowToken(userWalletId: String, currencyId: String) {
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val storesCurrencies = mutablePreferences.getOrDefault(IS_TOKEN_SWAP_PROMO_SHOW_KEY, emptySet())
|
||||
mutablePreferences.set(
|
||||
key = IS_TOKEN_SWAP_PROMO_SHOW_KEY,
|
||||
value = storesCurrencies.toMutableSet().apply { add(userWalletId + currencyId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPromoPeriod(): Boolean {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import kotlinx.coroutines.flow.Flow
|
|||
|
||||
class ShouldShowSwapPromoTokenUseCase(private val swapPromoRepository: SwapPromoRepository) {
|
||||
|
||||
operator fun invoke(): Flow<Boolean> = swapPromoRepository.isReadyToShowToken()
|
||||
operator fun invoke(userWalletId: String, currencyId: String): Flow<Boolean> =
|
||||
swapPromoRepository.isReadyToShowToken(userWalletId, currencyId)
|
||||
|
||||
suspend fun neverToShow() = swapPromoRepository.setNeverToShowToken()
|
||||
suspend fun neverToShow(userWalletId: String, currencyId: String) =
|
||||
swapPromoRepository.setNeverToShowToken(userWalletId, currencyId)
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.Flow
|
|||
interface SwapPromoRepository {
|
||||
fun isReadyToShowWallet(): Flow<Boolean>
|
||||
|
||||
fun isReadyToShowToken(): Flow<Boolean>
|
||||
fun isReadyToShowToken(userWalletId: String, currencyId: String): Flow<Boolean>
|
||||
|
||||
suspend fun setNeverToShowWallet()
|
||||
|
||||
suspend fun setNeverToShowToken()
|
||||
suspend fun setNeverToShowToken(userWalletId: String, currencyId: String)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
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
|
||||
|
|
@ -54,18 +53,16 @@ class GetCurrencyWarningsUseCase(
|
|||
derivationPath = derivationPath,
|
||||
isSingleWalletWithTokens = isSingleWalletWithTokens,
|
||||
),
|
||||
operations.getCurrenciesStatusesFlow().conflate(),
|
||||
flowOf(walletManagersFacade.getRentInfo(userWalletId, currency.network)),
|
||||
flowOf(walletManagersFacade.getExistentialDeposit(userWalletId, currency.network)),
|
||||
showSwapPromoTokenUseCase().conflate(),
|
||||
) { coinRelatedWarnings, cryptoStatuses, maybeRentWarning, maybeEdWarning, shouldShowSwapPromo ->
|
||||
getSwapPromoNotificationWarning(
|
||||
operations = operations,
|
||||
userWalletId = userWalletId,
|
||||
currencyStatus = currencyStatus,
|
||||
).conflate(),
|
||||
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeSwapPromo ->
|
||||
setOfNotNull(
|
||||
getSwapPromoNotificationWarning(
|
||||
shouldShowSwapPromo = shouldShowSwapPromo,
|
||||
userWalletId = userWalletId,
|
||||
currencyStatus = currencyStatus,
|
||||
cryptoStatuses = cryptoStatuses,
|
||||
),
|
||||
maybeSwapPromo,
|
||||
maybeRentWarning,
|
||||
maybeEdWarning?.let {
|
||||
CryptoCurrencyWarning.ExistentialDeposit(
|
||||
|
|
@ -81,57 +78,59 @@ class GetCurrencyWarningsUseCase(
|
|||
}
|
||||
|
||||
private suspend fun getSwapPromoNotificationWarning(
|
||||
shouldShowSwapPromo: Boolean,
|
||||
operations: CurrenciesStatusesOperations,
|
||||
userWalletId: UserWalletId,
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
cryptoStatuses: Either<CurrenciesStatusesOperations.Error, List<CryptoCurrencyStatus>>,
|
||||
): CryptoCurrencyWarning? {
|
||||
): Flow<CryptoCurrencyWarning?> {
|
||||
val currency = currencyStatus.currency
|
||||
return if (shouldShowSwapPromo && marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)) {
|
||||
cryptoStatuses.fold(
|
||||
ifLeft = { null },
|
||||
ifRight = { cryptoCurrencyStatuses ->
|
||||
val pairs = runCatching(dispatchers.io) {
|
||||
swapRepository.getPairs(
|
||||
LeastTokenInfo(
|
||||
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",
|
||||
network = currency.network.backendId,
|
||||
),
|
||||
cryptoCurrencyStatuses.map { it.currency },
|
||||
)
|
||||
}.getOrNull()?.pairs ?: emptyList()
|
||||
val cryptoStatuses = operations.getCurrenciesStatusesSync()
|
||||
return combine(
|
||||
showSwapPromoTokenUseCase(userWalletId.stringValue, currency.id.value).conflate(),
|
||||
flowOf(marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)).conflate(),
|
||||
) { shouldShowSwapPromo, isExchangeable ->
|
||||
if (shouldShowSwapPromo && isExchangeable) {
|
||||
cryptoStatuses.fold(
|
||||
ifLeft = { null },
|
||||
ifRight = { cryptoCurrencyStatuses ->
|
||||
val pairs = runCatching(dispatchers.io) {
|
||||
swapRepository.getPairsOnly(
|
||||
LeastTokenInfo(
|
||||
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",
|
||||
network = currency.network.backendId,
|
||||
),
|
||||
cryptoCurrencyStatuses.map { it.currency },
|
||||
)
|
||||
}.getOrNull()?.pairs ?: emptyList()
|
||||
|
||||
val filteredCurrencies = cryptoCurrencyStatuses.filterNot {
|
||||
it.currency.id == currency.id
|
||||
}
|
||||
val currencyPairs = 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.isNullOrZero()) {
|
||||
filteredCurrencies.filterNot { it.value.amount.isNullOrZero() }
|
||||
} else {
|
||||
filteredCurrencies
|
||||
val filteredCurrencies = cryptoCurrencyStatuses.filterNot {
|
||||
it.currency.id == currency.id
|
||||
}
|
||||
availablePair
|
||||
.any {
|
||||
(
|
||||
val currencyPairs = pairs.filter {
|
||||
it.from.network == currency.network.backendId ||
|
||||
it.to.network == currency.network.backendId
|
||||
}
|
||||
val showPromo = currencyPairs.any { pair ->
|
||||
val availablePair = if (currencyStatus.value.amount.isNullOrZero()) {
|
||||
filteredCurrencies.filterNot { it.value.amount.isNullOrZero() }
|
||||
} else {
|
||||
filteredCurrencies
|
||||
}
|
||||
availablePair
|
||||
.any {
|
||||
it.currency.network.backendId == pair.to.network ||
|
||||
it.currency.network.backendId == pair.from.network
|
||||
) &&
|
||||
!it.value.amount.isNullOrZero()
|
||||
}
|
||||
}
|
||||
if (isExchangeable) {
|
||||
CryptoCurrencyWarning.SwapPromo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (showPromo) {
|
||||
CryptoCurrencyWarning.SwapPromo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import java.math.BigDecimal
|
|||
import javax.inject.Inject
|
||||
import com.tangem.datasource.api.express.models.request.LeastTokenInfo as NetworkLeastTokenInfo
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
internal class SwapRepositoryImpl @Inject constructor(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val tangemExpressApi: TangemExpressApi,
|
||||
|
|
@ -109,6 +109,53 @@ internal class SwapRepositoryImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getPairsOnly(
|
||||
initialCurrency: LeastTokenInfo,
|
||||
currencyList: List<CryptoCurrency>,
|
||||
): PairsWithProviders {
|
||||
return withContext(coroutineDispatcher.io) {
|
||||
try {
|
||||
val initial = NetworkLeastTokenInfo(
|
||||
contractAddress = initialCurrency.contractAddress,
|
||||
network = initialCurrency.network,
|
||||
)
|
||||
val currenciesList = currencyList.map { leastTokenInfoConverter.convert(it) }
|
||||
|
||||
val pairsDeferred = async {
|
||||
getPairsInternal(
|
||||
from = arrayListOf(initial),
|
||||
to = currenciesList,
|
||||
)
|
||||
}
|
||||
|
||||
val reversedPairsDeferred = async {
|
||||
getPairsInternal(
|
||||
from = currenciesList,
|
||||
to = arrayListOf(initial),
|
||||
)
|
||||
}
|
||||
|
||||
val pairs = pairsDeferred.await().getOrThrow()
|
||||
val reversedPairs = reversedPairsDeferred.await().getOrThrow()
|
||||
|
||||
val allPairs = pairs + reversedPairs
|
||||
|
||||
return@withContext swapPairInfoConverter.convert(
|
||||
SwapPairsWithProviders(
|
||||
swapPair = allPairs,
|
||||
providers = emptyList(),
|
||||
),
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is ApiResponseError.HttpException) {
|
||||
throw ExpressException(errorsDataConverter.convert(exception.errorBody ?: ""))
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPairsInternal(
|
||||
from: List<NetworkLeastTokenInfo>,
|
||||
to: List<NetworkLeastTokenInfo>,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
|
|||
name = exchangeProvider.name,
|
||||
type = convertExchangeType(exchangeProvider.type),
|
||||
imageLarge = exchangeProvider.imageLargeUrl,
|
||||
termsOfUse = exchangeProvider.termsOfUse,
|
||||
privacyPolicy = exchangeProvider.privacyPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +59,8 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
|
|||
name = additionalProvider.name,
|
||||
type = convertExchangeType(additionalProvider.type),
|
||||
imageLarge = additionalProvider.imageLargeUrl,
|
||||
termsOfUse = additionalProvider.termsOfUse,
|
||||
privacyPolicy = additionalProvider.privacyPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ interface SwapRepository {
|
|||
|
||||
suspend fun getPairs(initialCurrency: LeastTokenInfo, currencyList: List<CryptoCurrency>): PairsWithProviders
|
||||
|
||||
/** Express getPairs request variant without providers request */
|
||||
suspend fun getPairsOnly(initialCurrency: LeastTokenInfo, currencyList: List<CryptoCurrency>): PairsWithProviders
|
||||
|
||||
suspend fun getRates(currencyId: String, tokenIds: List<String>): Map<String, Double>
|
||||
|
||||
suspend fun getExchangeableTokens(networkId: String): List<Currency>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ data class SwapProvider(
|
|||
val name: String,
|
||||
val type: ExchangeProviderType,
|
||||
val imageLarge: String,
|
||||
val termsOfUse: String?,
|
||||
val privacyPolicy: String?,
|
||||
)
|
||||
|
||||
enum class ExchangeProviderType {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ dependencies {
|
|||
|
||||
/** Other libraries */
|
||||
implementation(deps.compose.shimmer)
|
||||
implementation(deps.compose.accompanist.webView)
|
||||
implementation(deps.compose.accompanist.systemUiController)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.timber)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ data class SwapStateHolder(
|
|||
val bottomSheetConfig: TangemBottomSheetConfig? = null,
|
||||
|
||||
val swapButton: SwapButton,
|
||||
val tosState: TosState? = null,
|
||||
|
||||
val onRefresh: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
|
|
@ -106,6 +107,17 @@ sealed interface TransactionCardType {
|
|||
) : TransactionCardType
|
||||
}
|
||||
|
||||
data class TosState(
|
||||
val tosLink: LegalState?,
|
||||
val policyLink: LegalState?,
|
||||
)
|
||||
|
||||
data class LegalState(
|
||||
val title: TextReference,
|
||||
val link: String,
|
||||
val onClick: (String) -> Unit,
|
||||
)
|
||||
|
||||
sealed interface SwapWarning {
|
||||
data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning
|
||||
object InsufficientFunds : SwapWarning
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ data class UiActions(
|
|||
val onProviderClick: (String) -> Unit,
|
||||
val onProviderSelect: (String) -> Unit,
|
||||
val onBuyClick: () -> Unit,
|
||||
val onPolicyClick: (String) -> Unit,
|
||||
val onTosClick: (String) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
|
||||
internal data class WebViewBottomSheetConfig(val url: String) : TangemBottomSheetConfigContent
|
||||
|
|
@ -247,7 +247,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
|
|||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Provider",
|
||||
text = stringResource(R.string.express_provider),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
|
||||
|
|
@ -265,7 +265,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
|
|||
strokeWidth = TangemTheme.dimens.size2,
|
||||
)
|
||||
Text(
|
||||
text = "Fetching best rates ...",
|
||||
text = stringResource(R.string.express_fetch_best_rates),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
|
||||
|
|
|
|||
|
|
@ -271,6 +271,26 @@ internal class StateBuilder(
|
|||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
),
|
||||
tosState = createTosState(swapProvider),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTosState(swapProvider: SwapProvider): TosState {
|
||||
return TosState(
|
||||
tosLink = swapProvider.termsOfUse?.let {
|
||||
LegalState(
|
||||
title = resourceReference(R.string.express_terms_of_use),
|
||||
link = it,
|
||||
onClick = actions.onTosClick,
|
||||
)
|
||||
},
|
||||
policyLink = swapProvider.privacyPolicy?.let {
|
||||
LegalState(
|
||||
title = resourceReference(R.string.express_privacy_policy),
|
||||
link = it,
|
||||
onClick = actions.onPolicyClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +442,7 @@ internal class StateBuilder(
|
|||
ChangeCardsButtonState.DISABLED
|
||||
},
|
||||
providerState = providerState,
|
||||
tosState = createTosState(swapProvider),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -804,6 +825,17 @@ internal class StateBuilder(
|
|||
}
|
||||
}
|
||||
|
||||
fun showWebViewBottomSheet(uiState: SwapStateHolder, url: String, onDismiss: () -> Unit): SwapStateHolder {
|
||||
val config = WebViewBottomSheetConfig(url = url)
|
||||
return uiState.copy(
|
||||
bottomSheetConfig = TangemBottomSheetConfig(
|
||||
isShow = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = config,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder {
|
||||
val permissionState = uiState.permissionState
|
||||
if (permissionState is SwapPermissionState.ReadyForRequest) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ package com.tangem.feature.swap.ui
|
|||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -14,6 +13,7 @@ import com.tangem.feature.swap.models.SwapStateHolder
|
|||
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.WebViewBottomSheetConfig
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
@Composable
|
||||
|
|
@ -48,6 +48,9 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
|
|||
is ChooseFeeBottomSheetConfig -> {
|
||||
ChooseFeeBottomSheet(config = config)
|
||||
}
|
||||
is WebViewBottomSheetConfig -> {
|
||||
WebViewBottomSheet(config = config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -11,8 +12,12 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.constraintlayout.compose.ConstraintLayout
|
||||
import com.tangem.common.Strings.STARS
|
||||
|
|
@ -72,6 +77,15 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
|
|||
}
|
||||
}
|
||||
|
||||
state.tosState?.let {
|
||||
ProviderTos(
|
||||
tosState = it,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = keyboard is Keyboard.Opened,
|
||||
modifier = Modifier
|
||||
|
|
@ -185,6 +199,84 @@ private fun TransactionCardData(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
|
||||
val tos = tosState.tosLink
|
||||
val policy = tosState.policyLink
|
||||
if (tos == null && policy == null) return
|
||||
|
||||
val (annotatedString, click) = getAnnotatedStringForLegalsWithClick(tos, policy)
|
||||
|
||||
ClickableText(
|
||||
text = annotatedString,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TangemTheme.dimens.spacing54),
|
||||
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Center),
|
||||
onClick = click,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getAnnotatedStringForLegalsWithClick(
|
||||
tos: LegalState?,
|
||||
policy: LegalState?,
|
||||
): Pair<AnnotatedString, (Int) -> Unit> {
|
||||
return if (tos != null && policy != null) {
|
||||
val tosTitle = tos.title.resolveReference()
|
||||
val policyTitle = policy.title.resolveReference()
|
||||
val fullString = stringResource(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
|
||||
val tosIndex = fullString.indexOf(tosTitle)
|
||||
val policyIndex = fullString.indexOf(policyTitle)
|
||||
val string = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(0, tosIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(tosIndex, tosIndex + tosTitle.length))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(policyIndex, policyIndex + policyTitle.length))
|
||||
}
|
||||
}
|
||||
val click = { i: Int ->
|
||||
val tosStyle = requireNotNull(string.spanStyles.getOrNull(1))
|
||||
if (i in tosStyle.start..tosStyle.end) {
|
||||
tos.onClick(tos.link)
|
||||
}
|
||||
val policyStyle = requireNotNull(string.spanStyles.lastOrNull())
|
||||
if (i in policyStyle.start..policyStyle.end) {
|
||||
policy.onClick(policy.link)
|
||||
}
|
||||
}
|
||||
string to click
|
||||
} else {
|
||||
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
|
||||
val legalTitle = legal.title
|
||||
.resolveReference()
|
||||
val fullString = stringResource(id = R.string.express_legal_one_placeholder, legal)
|
||||
val legalIndex = fullString.indexOf(legalTitle)
|
||||
val string = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(0, legalIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(legalIndex, legalIndex + legalTitle.length))
|
||||
}
|
||||
}
|
||||
val click = { i: Int ->
|
||||
val legalStyle = requireNotNull(string.spanStyles.lastOrNull())
|
||||
if (i in legalStyle.start..legalStyle.end) {
|
||||
legal.onClick(legal.link)
|
||||
}
|
||||
}
|
||||
string to click
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
||||
|
|
@ -407,6 +499,18 @@ private val state = SwapStateHolder(
|
|||
permissionState = SwapPermissionState.InProgress,
|
||||
blockchainId = "POLYGON",
|
||||
providerState = ProviderState.Loading(),
|
||||
tosState = TosState(
|
||||
tosLink = LegalState(
|
||||
title = stringReference("Tangem"),
|
||||
link = "https://tangem.com",
|
||||
onClick = {},
|
||||
),
|
||||
policyLink = LegalState(
|
||||
title = stringReference("Tangem"),
|
||||
link = "https://tangem.com",
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.swap.models.states.WebViewBottomSheetConfig
|
||||
|
||||
@Composable
|
||||
fun WebViewBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet(
|
||||
config = config,
|
||||
contentColor = TangemTheme.colors.background.tertiary,
|
||||
) { content: WebViewBottomSheetConfig ->
|
||||
WebViewBottomSheetContent(
|
||||
WebViewBottomSheetConfig(url = content.url),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebViewBottomSheetContent(content: WebViewBottomSheetConfig) {
|
||||
val state = rememberWebViewState(content.url)
|
||||
val isInPreviewMode = LocalInspectionMode.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.background.secondary),
|
||||
captureBackPresses = false,
|
||||
onCreated = {
|
||||
if (!isInPreviewMode) {
|
||||
it.settings.apply {
|
||||
javaScriptEnabled = false
|
||||
allowFileAccess = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview_WebViewBottomSheetContent_InLightTheme() {
|
||||
TangemTheme(isDark = false) {
|
||||
WebViewBottomSheetContent(
|
||||
content = WebViewBottomSheetConfig(
|
||||
url = "https://tangem.com/en/",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview_WebViewBottomSheetContent_InDarkTheme() {
|
||||
TangemTheme(isDark = true) {
|
||||
WebViewBottomSheetContent(
|
||||
content = WebViewBottomSheetConfig(
|
||||
url = "https://tangem.com/en/",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.ui.*
|
||||
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.presentation.SwapFragment
|
||||
import com.tangem.feature.swap.router.SwapNavScreen
|
||||
|
|
@ -845,6 +845,16 @@ internal class SwapViewModel @Inject constructor(
|
|||
onRetryClick = {
|
||||
startLoadingQuotesFromLastState()
|
||||
},
|
||||
onPolicyClick = {
|
||||
uiState = stateBuilder.showWebViewBottomSheet(uiState, it) {
|
||||
uiState = stateBuilder.dismissBottomSheet(uiState)
|
||||
}
|
||||
},
|
||||
onTosClick = {
|
||||
uiState = stateBuilder.showWebViewBottomSheet(uiState, it) {
|
||||
uiState = stateBuilder.dismissBottomSheet(uiState)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
viewModelScope.launch(dispatchers.io) {
|
||||
swapTxStatusTaskScheduler.cancelTask()
|
||||
exchangeStatusFactory.invoke()
|
||||
.distinctUntilChanged()
|
||||
.filterNot { it.isEmpty() }
|
||||
.onEach { swapTxs ->
|
||||
swapTxStatusTaskScheduler.scheduleTask(
|
||||
viewModelScope,
|
||||
|
|
@ -230,13 +232,14 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
}
|
||||
},
|
||||
onSuccess = { updatedTxs ->
|
||||
val config = uiState.bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig
|
||||
val currentTx = updatedTxs.firstOrNull { it.txId == config?.value?.txId }
|
||||
val config = uiState.bottomSheetConfig
|
||||
val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig
|
||||
val currentTx = updatedTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId }
|
||||
uiState = uiState.copy(
|
||||
swapTxs = updatedTxs,
|
||||
bottomSheetConfig = currentTx?.let(
|
||||
stateFactory::updateStateWithExchangeStatusBottomSheet,
|
||||
),
|
||||
) ?: config,
|
||||
)
|
||||
},
|
||||
onError = {},
|
||||
|
|
@ -560,14 +563,17 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
|
||||
override fun onSwapPromoDismiss() {
|
||||
viewModelScope.launch(dispatchers.main) {
|
||||
shouldShowSwapPromoTokenUseCase.neverToShow()
|
||||
shouldShowSwapPromoTokenUseCase.neverToShow(userWalletId.stringValue, cryptoCurrency.id.value)
|
||||
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Close)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSwapPromoClick() {
|
||||
viewModelScope.launch(dispatchers.main) {
|
||||
shouldShowSwapPromoTokenUseCase.neverToShow(userWalletId.stringValue, cryptoCurrency.id.value)
|
||||
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Exchange(cryptoCurrency.symbol))
|
||||
}
|
||||
onSwapClick()
|
||||
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Exchange(cryptoCurrency.symbol))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue