diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt index 4e2bc6d69c..acbd56ec03 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt @@ -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 { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 1619c322de..0c38cd669b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -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 MutablePreferences.getOrDefault(key: Preferences.Key, default: T): T { + return this[key] ?: default + } + /** * Get nullable data [T] by string [key] from [MutablePreferences] * diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 26d5b3cf08..ffa0aeee60 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -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 */ diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 21bec9195d..5705d3bd04 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -197,17 +197,40 @@ Статус Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами Выберите провайдера - Чтобы узнать причину, посетите сайт провайдера - Чтобы вернуть ваши деньги, посетите сайт провайдера + Курс обмена + Чтобы вернуть ваши деньги, посетите сайт провайдера + Операция не выполнена провайдером Посетите сайт провайдера для проверки Провайдер запрашивает прохождение верификации + Подтверждено + Подтверждение + Подтверждение... + Обменяно + Обмен + Обмен... + Неудачно + Депозит получен + Ожидание депозита + Ожидаем пополнения... + Возвращено + Отправляем + Отправка средств... + Отправлено + Верифицировано + Требуется верификация Список токенов в вашем кошельке Получение наилучших курсов... + Плавающая ставка + К провайдеру + Пользуясь сервисом вы соглашаетесь с %s + Пользуясь сервисом вы соглашаетесь с %1$s и %2$s + Политикой конфиденциальности Провайдер Лучший курс Доступно с %s Недоступно для этой пары Требуется разрешение + Условиями использования Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. Скажите, пожалуйста, какая у вас карта? @@ -515,6 +538,8 @@ Ваш кошелек Чтобы продолжить, вам нужно разрешить смарт-контракту 1inch использовать ваш %s Безлимитно + Вы отправляете + Вы получите Открыть в обозревателе В процессе Обменять @@ -541,9 +566,9 @@ Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети. Невозможно скрыть %s Нет цены + Обменять Обменяйте этот токен на другие активы в вашем портфеле Представляем кроссчейн-своп - Обменять контракт: %s У вас еще нет транзакций Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию. @@ -656,6 +681,8 @@ У вас есть активная транзакция У вас в списке нет монет доступных для обмена с %s Нет доступных токенов для обмена + Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s + Невозможно покрыть комиссию %s Cервис временно недоступен Пожалуйста, измените сумму для обмена Сумма для обмена должна быть не менее %s diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 3088fc97ea..b81d198635 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -224,11 +224,15 @@ Fetching best rates... Floating rate Go to provider + By using swap functionality, you agree with provider’s %s + By using swap functionality, you agree with provider’s %1$s and %2$s + Privacy Policy Provider Best rate Available from %s Unavailable for this pair Permission Required + Terms of Use The following information is optional. You can erase it if you don\'t want to share it. Tell us what functions you are missing, and we will try to help you. Please tell us what card do you have diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt index dd94a2e07c..cfdc97ba1c 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt @@ -21,9 +21,9 @@ class DefaultSwapPromoRepository( .map { it && checkPromoPeriod() } } - override fun isReadyToShowToken(): Flow { - return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_SHOW_KEY, true) - .map { it && checkPromoPeriod() } + override fun isReadyToShowToken(userWalletId: String, currencyId: String): Flow { + 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 { diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt index 0c75a4f52a..6d7b0b30f9 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt @@ -5,7 +5,9 @@ import kotlinx.coroutines.flow.Flow class ShouldShowSwapPromoTokenUseCase(private val swapPromoRepository: SwapPromoRepository) { - operator fun invoke(): Flow = swapPromoRepository.isReadyToShowToken() + operator fun invoke(userWalletId: String, currencyId: String): Flow = + swapPromoRepository.isReadyToShowToken(userWalletId, currencyId) - suspend fun neverToShow() = swapPromoRepository.setNeverToShowToken() + suspend fun neverToShow(userWalletId: String, currencyId: String) = + swapPromoRepository.setNeverToShowToken(userWalletId, currencyId) } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt index 95d31f65d8..96d180f4df 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt @@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.Flow interface SwapPromoRepository { fun isReadyToShowWallet(): Flow - fun isReadyToShowToken(): Flow + fun isReadyToShowToken(userWalletId: String, currencyId: String): Flow suspend fun setNeverToShowWallet() - suspend fun setNeverToShowToken() + suspend fun setNeverToShowToken(userWalletId: String, currencyId: String) } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 3090438e08..ae959e251d 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -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>, - ): CryptoCurrencyWarning? { + ): Flow { 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 + } } } diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt index ffd12827ba..62c8ebb0b6 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt @@ -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, + ): 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, to: List, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt index 4ea0faf9cd..5bb5079f95 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt @@ -43,6 +43,8 @@ class SwapPairInfoConverter : Converter): PairsWithProviders + /** Express getPairs request variant without providers request */ + suspend fun getPairsOnly(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders + suspend fun getRates(currencyId: String, tokenIds: List): Map suspend fun getExchangeableTokens(networkId: String): List diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 76fa9a8238..9df61116fa 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -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 { diff --git a/features/swap/presentation/build.gradle.kts b/features/swap/presentation/build.gradle.kts index db0e737edf..a08a338a18 100644 --- a/features/swap/presentation/build.gradle.kts +++ b/features/swap/presentation/build.gradle.kts @@ -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) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 1502568f92..8b42445651 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -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 diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 48a3d55f55..258039517e 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -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, ) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt new file mode 100644 index 0000000000..ee2bce3961 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt @@ -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 \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 41adc2a361..4a217bcbfe 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -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), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index e66df2962d..89a2f5343e 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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) { diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index c25ea64c0b..cc39867e7d 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -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) + } } } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 360cd4e016..ba5cc5a853 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -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 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 diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt new file mode 100644 index 0000000000..4a068cddbd --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt @@ -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/", + ), + ) + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index b2c3060ae4..399f3b6f79 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -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) + } + }, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index 100aabe066..bafa950c0c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -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 {