Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-20 18:07:31 +02:00
parent d8844fda91
commit 5b516f2c9b
23 changed files with 339 additions and 52 deletions

View file

@ -20,4 +20,8 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_AB_ENABLED,
)
override val isSwapProviderFilterEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
)
}

View file

@ -195,7 +195,7 @@ internal class SwapModel @Inject constructor(
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isAccountsModeProvider = Provider { isAccountsMode },
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
swapFeatureToggles = swapFeatureToggles,
)
private val inputNumberFormatter = InputNumberFormatter(
@ -1596,6 +1596,9 @@ internal class SwapModel @Inject constructor(
)
}
},
onProviderFilterSelect = { filterType ->
uiState = stateBuilder.updateProviderFilterType(uiState, filterType)
},
onBuyClick = {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions
val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.models
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.TxFee
@ -22,6 +23,7 @@ internal data class UiActions(
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onProviderFilterSelect: (ProviderFilterType) -> Unit,
val onBuyClick: () -> Unit,
val onSelectTokenClick: (TokenSelectionDirection) -> Unit,
val onSuccess: () -> Unit,

View file

@ -2,10 +2,15 @@ package com.tangem.feature.swap.models.states
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.express.models.ProviderFilterType
import kotlinx.collections.immutable.ImmutableList
data class ChooseProviderBottomSheetConfig(
val selectedProviderId: String,
val providers: ImmutableList<ProviderState>,
val allProviders: ImmutableList<ProviderState>,
val notification: NotificationUM?,
val selectedFilter: ProviderFilterType,
val availableFilters: ImmutableList<ProviderFilterType>,
val onFilterSelect: (ProviderFilterType) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -5,14 +5,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -23,6 +30,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.provider.ProviderTypeFilterPicker
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.selectedBorder
import com.tangem.core.ui.extensions.stringReference
@ -40,9 +49,13 @@ fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
TangemModalBottomSheet<ChooseProviderBottomSheetConfig>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
title = {
title = { content ->
TangemModalBottomSheetTitle(
title = resourceReference(R.string.express_choose_providers_title),
title = if (content.availableFilters.isNotEmpty()) {
resourceReference(R.string.express_provider_for_swap)
} else {
resourceReference(R.string.express_choose_providers_title)
},
endIconRes = R.drawable.ic_close_24,
onEndClick = config.onDismissRequest,
)
@ -56,16 +69,39 @@ fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
@Suppress("LongMethod")
@Composable
private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResourceSafe(R.string.express_choose_providers_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(bottom = 14.dp)
.padding(horizontal = TangemTheme.dimens.spacing56),
textAlign = TextAlign.Center,
)
val density = LocalDensity.current
var minHeight by remember { mutableStateOf(0.dp) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.heightIn(min = minHeight)
.onSizeChanged { size ->
with(density) {
val h = size.height.toDp()
if (h > minHeight) minHeight = h
}
},
) {
if (content.availableFilters.isNotEmpty()) {
ProviderTypeFilterPicker(
availableFilters = content.availableFilters,
selectedFilter = content.selectedFilter,
onFilterSelect = content.onFilterSelect,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
)
} else {
Text(
text = stringResourceSafe(R.string.express_choose_providers_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(bottom = 14.dp)
.padding(horizontal = TangemTheme.dimens.spacing56),
textAlign = TextAlign.Center,
)
}
if (content.notification != null) {
Notification(
config = content.notification.config,
@ -160,6 +196,10 @@ private fun Preview_ChooseProviderBottomSheet() {
subtitle = resourceReference(R.string.warning_express_providers_fca_warning_description),
),
providers = providers,
allProviders = providers,
selectedFilter = ProviderFilterType.ALL,
availableFilters = persistentListOf(),
onFilterSelect = {},
)
TangemThemePreview {
ChooseProviderBottomSheet(

View file

@ -11,6 +11,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
@ -23,12 +24,12 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.*
@ -37,6 +38,7 @@ import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.Provider
@ -59,7 +61,7 @@ internal class StateBuilder(
private val appCurrencyProvider: Provider<AppCurrency>,
private val isAccountsModeProvider: Provider<Boolean>,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val shouldShowAbMenu: Boolean,
private val swapFeatureToggles: SwapFeatureToggles,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -99,7 +101,7 @@ internal class StateBuilder(
isInsufficientFunds = false,
swapUIMode = swapUIMode,
onSwapUIModeChange = actions.onSwapUIModeChange,
shouldShowAbMenu = shouldShowAbMenu,
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
)
}
@ -1029,10 +1031,28 @@ internal class StateBuilder(
val isAnyFCABadge = availableProvidersStates.any {
(it as? ProviderState.Content)?.additionalBadge == ProviderState.AdditionalBadge.FCAWarningList
}
val hasCex = availableProvidersStates.any { state ->
(state as? ProviderState.Content)?.type == ExchangeProviderType.CEX.providerName ||
(state as? ProviderState.Unavailable)?.type == ExchangeProviderType.CEX.providerName
}
val hasDex = availableProvidersStates.any { state ->
val providerType = (state as? ProviderState.Content)?.type ?: (state as? ProviderState.Unavailable)?.type
providerType == ExchangeProviderType.DEX.providerName ||
providerType == ExchangeProviderType.DEX_BRIDGE.providerName
}
val availableFilters = if (swapFeatureToggles.isSwapProviderFilterEnabled && hasCex && hasDex) {
persistentListOf(ProviderFilterType.ALL, ProviderFilterType.CEX, ProviderFilterType.DEX)
} else {
persistentListOf()
}
val config = ChooseProviderBottomSheetConfig(
selectedProviderId = selectedProviderId,
providers = availableProvidersStates,
allProviders = availableProvidersStates,
notification = SwapNotificationUM.Error.FCAWarningList.takeIf { isAnyFCABadge },
selectedFilter = ProviderFilterType.ALL,
availableFilters = availableFilters,
onFilterSelect = actions.onProviderFilterSelect,
)
return uiState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
@ -1050,23 +1070,24 @@ internal class StateBuilder(
): SwapStateHolder {
val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig
return if (config != null) {
val providers = config.providers
fun updateState(providerState: ProviderState): ProviderState {
val tokenInfo = tokenSwapInfoForProviders[providerState.id]
return if (providerState is ProviderState.Content && tokenInfo != null) {
providerState.copy(
subtitle = SwapProviderStateBuilder.buildSelectableSubtitle(tokenInfo),
percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent ->
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
)
} else {
providerState
}
}
uiState.copy(
bottomSheetConfig = uiState.bottomSheetConfig.copy(
content = config.copy(
providers = providers.map { providerState ->
val tokenInfo = tokenSwapInfoForProviders[providerState.id]
if (providerState is ProviderState.Content && tokenInfo != null) {
providerState.copy(
subtitle = SwapProviderStateBuilder.buildSelectableSubtitle(tokenInfo),
percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent ->
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
)
} else {
providerState
}
}.toImmutableList(),
providers = config.providers.map(::updateState).toImmutableList(),
allProviders = config.allProviders.map(::updateState).toImmutableList(),
),
),
)
@ -1075,6 +1096,19 @@ internal class StateBuilder(
}
}
fun updateProviderFilterType(uiState: SwapStateHolder, filterType: ProviderFilterType): SwapStateHolder {
val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig ?: return uiState
val filtered = config.allProviders.filter { matchesTypeFilter(it, filterType) }.toImmutableList()
return uiState.copy(
bottomSheetConfig = uiState.bottomSheetConfig.copy(
content = config.copy(
providers = filtered,
selectedFilter = filterType,
),
),
)
}
fun showSelectFeeBottomSheet(
uiState: SwapStateHolder,
selectedFee: FeeType,
@ -1208,4 +1242,18 @@ internal class StateBuilder(
is Account.Payment -> AccountIconUM.Payment
}
}
private fun matchesTypeFilter(state: ProviderState, filterType: ProviderFilterType): Boolean {
val typeStr = when (state) {
is ProviderState.Content -> state.type
is ProviderState.Unavailable -> state.type
else -> null
} ?: return filterType == ProviderFilterType.ALL
return when (filterType) {
ProviderFilterType.ALL -> true
ProviderFilterType.CEX -> typeStr == ExchangeProviderType.CEX.providerName
ProviderFilterType.DEX -> typeStr == ExchangeProviderType.DEX.providerName ||
typeStr == ExchangeProviderType.DEX_BRIDGE.providerName
}
}
}

View file

@ -17,6 +17,7 @@ import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
@ -31,6 +32,7 @@ internal class StateBuilderInitialStateTest {
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private lateinit var sut: StateBuilder
@ -48,7 +50,7 @@ internal class StateBuilderInitialStateTest {
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
swapFeatureToggles = swapFeatureToggles,
)
}

View file

@ -12,6 +12,7 @@ import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
@ -26,6 +27,7 @@ internal class StateBuilderPairsTest {
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private lateinit var sut: StateBuilder
@ -53,7 +55,7 @@ internal class StateBuilderPairsTest {
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
swapFeatureToggles = swapFeatureToggles,
)
}

View file

@ -14,6 +14,7 @@ import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
@ -29,6 +30,7 @@ internal class StateBuilderQuotesTest {
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private lateinit var sut: StateBuilder
@ -57,7 +59,7 @@ internal class StateBuilderQuotesTest {
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
swapFeatureToggles = swapFeatureToggles,
)
}

View file

@ -12,6 +12,7 @@ import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
@ -31,6 +32,7 @@ internal class StateBuilderSwapDataTest {
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private lateinit var sut: StateBuilder
@ -59,7 +61,7 @@ internal class StateBuilderSwapDataTest {
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
swapFeatureToggles = swapFeatureToggles,
)
}