Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-15 18:00:44 +03:00
commit 8c5c799536
24 changed files with 438 additions and 81 deletions

View file

@ -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 {

View file

@ -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]
*

View file

@ -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> */

View file

@ -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>

View file

@ -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 providers %s</string>
<string name="express_legal_two_placeholders">By using swap functionality, you agree with providers %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>

View file

@ -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 {

View file

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

View file

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

View file

@ -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
}
}
}

View file

@ -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>,

View file

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

View file

@ -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>

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -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) {

View file

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

View file

@ -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

View file

@ -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/",
),
)
}
}

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

View file

@ -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 {