Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-15 17:28:03 +03:00
parent 2d34e8cb1f
commit b19deae346
14 changed files with 274 additions and 3 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

@ -203,11 +203,17 @@
<string name="express_exchange_notification_verification_title">Провайдер запрашивает прохождение верификации</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>

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

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

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

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