Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-22 22:24:38 +03:00
parent 1d00d93dc0
commit 0ff8622215
14 changed files with 813 additions and 467 deletions

View file

@ -43,11 +43,9 @@ internal class SwapRepositoryImpl @Inject constructor(
) : SwapRepository {
private val tokensConverter = TokensConverter()
private val quotesConverter = QuotesConverter()
private val swapConverter = SwapConverter()
private val leastTokenInfoConverter = LeastTokenInfoConverter()
private val swapPairInfoConverter = SwapPairInfoConverter()
private val rateTypeConverter = RateTypeConverter()
private val swapProviderConverter = SwapProviderConverter()
override suspend fun getPairs(

View file

@ -389,27 +389,6 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
@Deprecated("used in old swap mechanism")
private fun selectToToken(
initialToken: Currency,
tokensInWallet: List<Currency>,
loadedTokens: List<Currency>,
): Currency {
val toToken = if (tokensInWallet.isNotEmpty()) {
tokensInWallet.firstOrNull { it.symbol != initialToken.symbol }
?: loadedTokens.first { it.symbol != initialToken.symbol }
} else {
val findUsdt = loadedTokens.firstOrNull { it.symbol == USDT_SYMBOL && it.symbol != initialToken.symbol }
if (findUsdt == null) {
val findUsdc = loadedTokens.firstOrNull { it.symbol == USDC_SYMBOL && it.symbol != initialToken.symbol }
findUsdc ?: loadedTokens.first { it.symbol != initialToken.symbol }
} else {
findUsdt
}
}
return toToken
}
@Suppress("UnusedPrivateMember")
@Deprecated("used in old swap mechanism")
private fun getTokensWithBalance(
@ -890,8 +869,6 @@ internal class SwapInteractorImpl @Inject constructor(
@Suppress("UnusedPrivateMember")
private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.0 // if need to increase fee when check isEnough
private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
private const val USDT_SYMBOL = "USDT"
private const val USDC_SYMBOL = "USDC"
private const val INFINITY_SYMBOL = ""
private val ONE_INCH_SUPPORTED_NETWORKS = listOf(

View file

@ -37,6 +37,7 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.compose.foundation)
implementation(deps.compose.material)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.compose.constraintLayout)

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.states.Item
import com.tangem.core.ui.components.states.SelectableItemsState
@ -22,6 +23,7 @@ data class SwapStateHolder(
val permissionState: SwapPermissionState = SwapPermissionState.Empty,
val successState: SwapSuccessStateHolder? = null,
val selectTokenState: SwapSelectTokenStateHolder? = null,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
val swapButton: SwapButton,

View file

@ -21,4 +21,6 @@ data class UiActions(
// region new actions
val onClickFee: () -> Unit,
val onSelectFeeType: (FeeType) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
)

View file

@ -0,0 +1,9 @@
package com.tangem.feature.swap.models.states
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.feature.swap.models.SwapPermissionState
class GivePermissionBottomSheetConfig(
val data: SwapPermissionState.ReadyForRequest,
val onCancel: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -26,7 +26,7 @@ fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
@Composable
private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) {
Column(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Text(
text = "Choose provider",

View file

@ -324,7 +324,7 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = "Best trade",
text = "Best rate",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.Provider
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.states.Item
import com.tangem.core.ui.components.states.SelectableItemsState
@ -21,11 +22,14 @@ import com.tangem.feature.swap.domain.models.domain.SwapProvider
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.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
/**
* State builder creates a specific states for SwapScreen
@ -224,7 +228,7 @@ internal class StateBuilder(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = fromCurrencyStatus.getFormattedAmount(quoteModel.fromTokenInfo.amountFiat),
amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat),
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
coinId = fromCurrencyStatus.currency.network.backendId,
isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken,
@ -236,7 +240,7 @@ internal class StateBuilder(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()),
amountEquivalent = toCurrencyStatus.getFormattedAmount(quoteModel.toTokenInfo.amountFiat),
amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toCurrencyStatus.currency.network.backendId,
isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken,
@ -266,6 +270,7 @@ internal class StateBuilder(
providerState = swapProvider.convertToContentClickableProviderState(
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
onProviderClick = actions.onProviderClick,
),
)
}
@ -709,6 +714,66 @@ internal class StateBuilder(
}
}
fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder {
val permissionState = uiState.permissionState
if (permissionState is SwapPermissionState.ReadyForRequest) {
val config = GivePermissionBottomSheetConfig(
data = permissionState,
onCancel = onDismiss,
)
return uiState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = onDismiss,
content = config,
),
)
}
return uiState
}
fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
bottomSheetConfig = uiState.bottomSheetConfig?.copy(isShow = false),
)
}
fun showSelectProviderBottomSheet(
uiState: SwapStateHolder,
selectedProviderId: String,
providersStates: Map<SwapProvider, SwapState>,
onDismiss: () -> Unit,
): SwapStateHolder {
val config = ChooseProviderBottomSheetConfig(
selectedProviderId = selectedProviderId,
providers = providersStates.entries
.mapNotNull { it.convertToProviderState(actions.onProviderSelect) }
.toImmutableList(),
)
return uiState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = onDismiss,
content = config,
),
)
}
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderState(
onProviderSelect: (String) -> Unit,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.QuotesLoadedState -> provider.convertToContentClickableProviderState(
state.fromTokenInfo,
state.toTokenInfo,
onProviderClick = onProviderSelect,
)
is SwapState.SwapError -> null
}
}
private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.swapping_permission_header),
@ -724,7 +789,7 @@ internal class StateBuilder(
return NotificationConfig(
title = resourceReference(R.string.swapping_high_price_impact),
subtitle = resourceReference(R.string.swapping_high_price_impact_description),
iconResId = R.drawable.ic_locked_24,
iconResId = R.drawable.ic_alert_circle_24,
)
}
@ -741,8 +806,13 @@ internal class StateBuilder(
private fun SwapProvider.convertToContentClickableProviderState(
fromTokenInfo: TokenSwapInfo,
toTokenInfo: TokenSwapInfo,
onProviderClick: (String) -> Unit,
): ProviderState {
val rate = fromTokenInfo.tokenAmount.value / toTokenInfo.tokenAmount.value
val rate = toTokenInfo.tokenAmount.value.divide(
fromTokenInfo.tokenAmount.value,
toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
@ -755,7 +825,7 @@ internal class StateBuilder(
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
selectionType = ProviderState.SelectionType.CLICK,
percentLowerThenBest = null,
onProviderClick = {},
onProviderClick = onProviderClick,
)
}
@ -765,10 +835,6 @@ internal class StateBuilder(
return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals)
}
private fun CryptoCurrencyStatus.getFormattedAmount(amount: BigDecimal): String {
return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals)
}
@Suppress("UnusedPrivateMember")
private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String {
val fiatAmount = value.fiatAmount ?: return UNKNOWN_AMOUNT_SIGN
@ -777,6 +843,12 @@ internal class StateBuilder(
return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol)
}
private fun getFormattedFiatAmount(amount: BigDecimal): String {
val appCurrency = appCurrencyProvider()
return BigDecimalFormatter.formatFiatAmount(amount, appCurrency.code, appCurrency.symbol)
}
private companion object {
const val ADDRESS_MIN_LENGTH = 11
const val ADDRESS_FIRST_PART_LENGTH = 7

View file

@ -0,0 +1,327 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.ApprovePermissionButton
import com.tangem.feature.swap.models.ApproveType
import com.tangem.feature.swap.models.CancelPermissionButton
import com.tangem.feature.swap.models.SwapPermissionState
import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SwapPermissionBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: GivePermissionBottomSheetConfig ->
SwapPermissionBottomSheetContent(content = content)
}
}
@Composable
private fun SwapPermissionBottomSheetContent(content: GivePermissionBottomSheetConfig) {
var isPermissionAlertShow by remember { mutableStateOf(false) }
val data = content.data
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Hand()
SpacerH10()
Box(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.align(Alignment.Center),
text = stringResource(id = R.string.swapping_permission_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
)
IconButton(
modifier = Modifier.align(Alignment.CenterEnd),
onClick = { isPermissionAlertShow = true },
) {
Icon(
painter = painterResource(id = R.drawable.ic_question_24),
contentDescription = null,
)
}
}
SpacerH10()
Text(
text = stringResource(
id = R.string.swapping_permission_subheader,
data.currency,
),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing8),
)
SpacerH16()
ApprovalBottomSheetInfo(data)
SpacerH28()
PrimaryButtonIconEnd(
text = stringResource(id = R.string.swapping_permission_buttons_approve),
iconResId = R.drawable.ic_tangem_24,
modifier = Modifier.fillMaxWidth(),
onClick = data.approveButton.onClick,
)
SpacerH12()
SecondaryButton(
text = stringResource(id = R.string.common_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = {
content.onCancel()
},
)
SpacerH16()
// region dialog
if (isPermissionAlertShow) {
BasicDialog(
message = stringResource(id = R.string.swapping_approve_information_text),
title = stringResource(id = R.string.swapping_approve_information_title),
confirmButton = DialogButton { isPermissionAlertShow = false },
onDismissDialog = {},
)
}
}
}
@Composable
private fun ApprovalBottomSheetInfo(data: SwapPermissionState.ReadyForRequest) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AmountItem(
currency = data.currency,
approveType = data.approveType,
onChangeApproveType = data.onChangeApproveType,
approveItems = data.approveItems,
)
SubtitleItem(
subtitle = stringResource(id = R.string.swapping_permission_policy_type_footer),
modifier = Modifier.fillMaxWidth(),
)
SpacerH24()
DividerBottomSheet()
FeeItem(fee = data.fee.resolveReference())
SubtitleItem(
subtitle = stringResource(id = R.string.swapping_permission_fee_footer),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun DividerBottomSheet() {
Divider(
color = TangemTheme.colors.stroke.primary,
thickness = TangemTheme.dimens.size0_5,
)
}
@Composable
private fun InformationItem(subtitle: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = subtitle,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
MiddleEllipsisText(
text = value,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun AmountItem(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
) {
var isExpandSelector by remember {
mutableStateOf(false)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.swapping_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
Box {
SelectorItem(
getTitleForApproveType(approveType = approveType),
) {
isExpandSelector = true
}
DropdownSelector(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { approveType ->
isExpandSelector = false
onChangeApproveType.invoke(approveType)
},
items = approveItems,
)
}
}
}
@Composable
private fun SelectorItem(title: String, onClick: () -> Unit) {
Row(
modifier = Modifier.clickable { onClick() },
) {
Text(
text = title,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
}
@Composable
private fun DropdownSelector(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.secondary),
) {
items.forEach { item ->
DropdownMenuItem(
onClick = {
onItemClick.invoke(item)
},
) {
Text(
text = getTitleForApproveType(approveType = item),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
}
}
}
}
@Composable
private fun FeeItem(fee: String) {
InformationItem(
subtitle = stringResource(id = R.string.send_fee_label),
value = fee,
)
}
@Composable
private fun SubtitleItem(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun getTitleForApproveType(approveType: ApproveType): String = when (approveType) {
ApproveType.LIMITED -> stringResource(id = R.string.swapping_permission_current_transaction)
ApproveType.UNLIMITED -> stringResource(id = R.string.swapping_permission_unlimited)
}
// region preview
@Preview
@Composable
private fun Preview_AgreementBottomSheet_InLightTheme() {
TangemTheme(isDark = false) {
SwapPermissionBottomSheetContent(content = previewData)
}
}
@Preview
@Composable
private fun Preview_AgreementBottomSheet_InDarkTheme() {
TangemTheme(isDark = true) {
SwapPermissionBottomSheetContent(content = previewData)
}
}
private val previewData = GivePermissionBottomSheetConfig(
data = SwapPermissionState.ReadyForRequest(
currency = "DAI",
amount = "",
walletAddress = "",
spenderAddress = "",
fee = TextReference.Str("2,14$"),
approveType = ApproveType.UNLIMITED,
approveButton = ApprovePermissionButton(true) {},
cancelButton = CancelPermissionButton(true),
onChangeApproveType = { ApproveType.UNLIMITED },
),
onCancel = {},
)

View file

@ -1,315 +1,297 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.ApprovePermissionButton
import com.tangem.feature.swap.models.ApproveType
import com.tangem.feature.swap.models.CancelPermissionButton
import com.tangem.feature.swap.models.SwapPermissionState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, onCancel: () -> Unit) {
var isPermissionAlertShow by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Hand()
// @Composable
// fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, onCancel: () -> Unit) {
// var isPermissionAlertShow by remember { mutableStateOf(false) }
// Column(
// modifier = Modifier
// .background(color = TangemTheme.colors.background.primary)
// .fillMaxWidth()
// .padding(horizontal = TangemTheme.dimens.spacing16),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// Hand()
//
// SpacerH10()
//
// Box(modifier = Modifier.fillMaxWidth()) {
// Text(
// modifier = Modifier.align(Alignment.Center),
// text = stringResource(id = R.string.swapping_permission_header),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// )
// IconButton(
// modifier = Modifier.align(Alignment.CenterEnd),
// onClick = { isPermissionAlertShow = true },
// ) {
// Icon(
// painter = painterResource(id = R.drawable.ic_question_24),
// contentDescription = null,
// )
// }
// }
//
// SpacerH10()
//
// Text(
// text = stringResource(
// id = R.string.swapping_permission_subheader,
// data.currency,
// ),
// color = TangemTheme.colors.text.secondary,
// style = TangemTheme.typography.body2,
// textAlign = TextAlign.Center,
// modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing8),
// )
//
// SpacerH16()
//
// ApprovalBottomSheetInfo(data)
//
// SpacerH28()
//
// PrimaryButtonIconEnd(
// text = stringResource(id = R.string.swapping_permission_buttons_approve),
// iconResId = R.drawable.ic_tangem_24,
// modifier = Modifier.fillMaxWidth(),
// onClick = data.approveButton.onClick,
// )
//
// SpacerH12()
//
// SecondaryButton(
// text = stringResource(id = R.string.common_cancel),
// modifier = Modifier.fillMaxWidth(),
// onClick = {
// onCancel()
// },
// )
//
// SpacerH16()
//
// // region dialog
// if (isPermissionAlertShow) {
// BasicDialog(
// message = stringResource(id = R.string.swapping_approve_information_text),
// title = stringResource(id = R.string.swapping_approve_information_title),
// confirmButton = DialogButton { isPermissionAlertShow = false },
// onDismissDialog = {},
// )
// }
// }
// }
SpacerH10()
Box(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.align(Alignment.Center),
text = stringResource(id = R.string.swapping_permission_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
)
IconButton(
modifier = Modifier.align(Alignment.CenterEnd),
onClick = { isPermissionAlertShow = true },
) {
Icon(
painter = painterResource(id = R.drawable.ic_question_24),
contentDescription = null,
)
}
}
SpacerH10()
Text(
text = stringResource(
id = R.string.swapping_permission_subheader,
data.currency,
),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing8),
)
SpacerH16()
ApprovalBottomSheetInfo(data)
SpacerH28()
PrimaryButtonIconEnd(
text = stringResource(id = R.string.swapping_permission_buttons_approve),
iconResId = R.drawable.ic_tangem_24,
modifier = Modifier.fillMaxWidth(),
onClick = data.approveButton.onClick,
)
SpacerH12()
SecondaryButton(
text = stringResource(id = R.string.common_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = {
onCancel()
},
)
SpacerH16()
// region dialog
if (isPermissionAlertShow) {
BasicDialog(
message = stringResource(id = R.string.swapping_approve_information_text),
title = stringResource(id = R.string.swapping_approve_information_title),
confirmButton = DialogButton { isPermissionAlertShow = false },
onDismissDialog = {},
)
}
}
}
@Composable
private fun ApprovalBottomSheetInfo(data: SwapPermissionState.ReadyForRequest) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AmountItem(
currency = data.currency,
approveType = data.approveType,
onChangeApproveType = data.onChangeApproveType,
approveItems = data.approveItems,
)
SubtitleItem(
subtitle = stringResource(id = R.string.swapping_permission_policy_type_footer),
modifier = Modifier.fillMaxWidth(),
)
SpacerH24()
DividerBottomSheet()
FeeItem(fee = data.fee.resolveReference())
SubtitleItem(
subtitle = stringResource(id = R.string.swapping_permission_fee_footer),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun DividerBottomSheet() {
Divider(
color = TangemTheme.colors.stroke.primary,
thickness = TangemTheme.dimens.size0_5,
)
}
@Composable
private fun InformationItem(subtitle: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = subtitle,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
MiddleEllipsisText(
text = value,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun AmountItem(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
) {
var isExpandSelector by remember {
mutableStateOf(false)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.swapping_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
Box {
SelectorItem(
getTitleForApproveType(approveType = approveType),
) {
isExpandSelector = true
}
DropdownSelector(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { approveType ->
isExpandSelector = false
onChangeApproveType.invoke(approveType)
},
items = approveItems,
)
}
}
}
@Composable
private fun SelectorItem(title: String, onClick: () -> Unit) {
Row(
modifier = Modifier.clickable { onClick() },
) {
Text(
text = title,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
}
@Composable
private fun DropdownSelector(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.secondary),
) {
items.forEach { item ->
DropdownMenuItem(
onClick = {
onItemClick.invoke(item)
},
) {
Text(
text = getTitleForApproveType(approveType = item),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
}
}
}
}
@Composable
private fun FeeItem(fee: String) {
InformationItem(
subtitle = stringResource(id = R.string.send_fee_label),
value = fee,
)
}
@Composable
private fun SubtitleItem(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun getTitleForApproveType(approveType: ApproveType): String = when (approveType) {
ApproveType.LIMITED -> stringResource(id = R.string.swapping_permission_current_transaction)
ApproveType.UNLIMITED -> stringResource(id = R.string.swapping_permission_unlimited)
}
// region preview
@Preview
@Composable
private fun Preview_AgreementBottomSheet_InLightTheme() {
TangemTheme(isDark = false) {
SwapPermissionBottomSheetContent(data = previewData) {}
}
}
@Preview
@Composable
private fun Preview_AgreementBottomSheet_InDarkTheme() {
TangemTheme(isDark = true) {
SwapPermissionBottomSheetContent(data = previewData) {}
}
}
private val previewData = SwapPermissionState.ReadyForRequest(
currency = "DAI",
amount = "",
walletAddress = "",
spenderAddress = "",
fee = TextReference.Str("2,14$"),
approveType = ApproveType.UNLIMITED,
approveButton = ApprovePermissionButton(true) {},
cancelButton = CancelPermissionButton(true),
onChangeApproveType = { ApproveType.UNLIMITED },
)
// @Composable
// private fun ApprovalBottomSheetInfo(data: SwapPermissionState.ReadyForRequest) {
// Column(
// modifier = Modifier
// .background(color = TangemTheme.colors.background.primary)
// .fillMaxWidth(),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// AmountItem(
// currency = data.currency,
// approveType = data.approveType,
// onChangeApproveType = data.onChangeApproveType,
// approveItems = data.approveItems,
// )
// SubtitleItem(
// subtitle = stringResource(id = R.string.swapping_permission_policy_type_footer),
// modifier = Modifier.fillMaxWidth(),
// )
// SpacerH24()
// DividerBottomSheet()
// FeeItem(fee = data.fee.resolveReference())
// SubtitleItem(
// subtitle = stringResource(id = R.string.swapping_permission_fee_footer),
// modifier = Modifier.fillMaxWidth(),
// )
// }
// }
//
// @Composable
// private fun DividerBottomSheet() {
// Divider(
// color = TangemTheme.colors.stroke.primary,
// thickness = TangemTheme.dimens.size0_5,
// )
// }
//
// @Composable
// private fun InformationItem(subtitle: String, value: String) {
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = TangemTheme.dimens.spacing16),
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// ) {
// Text(
// text = subtitle,
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// maxLines = 1,
// )
//
// MiddleEllipsisText(
// text = value,
// color = TangemTheme.colors.text.tertiary,
// style = TangemTheme.typography.body2,
// modifier = Modifier.padding(start = TangemTheme.dimens.spacing16),
// )
// }
// }
//
// @Composable
// private fun AmountItem(
// currency: String,
// approveType: ApproveType,
// approveItems: ImmutableList<ApproveType>,
// onChangeApproveType: (ApproveType) -> Unit,
// ) {
// var isExpandSelector by remember {
// mutableStateOf(false)
// }
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = TangemTheme.dimens.spacing16),
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// ) {
// Text(
// text = stringResource(id = R.string.swapping_permission_rows_amount, currency),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// maxLines = 1,
// )
// Box {
// SelectorItem(
// getTitleForApproveType(approveType = approveType),
// ) {
// isExpandSelector = true
// }
// DropdownSelector(
// isExpanded = isExpandSelector,
// onDismiss = { isExpandSelector = false },
// onItemClick = { approveType ->
// isExpandSelector = false
// onChangeApproveType.invoke(approveType)
// },
// items = approveItems,
// )
// }
// }
// }
//
// @Composable
// private fun SelectorItem(title: String, onClick: () -> Unit) {
// Row(
// modifier = Modifier.clickable { onClick() },
// ) {
// Text(
// text = title,
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.body1,
// maxLines = 1,
// )
// Icon(
// painter = painterResource(id = R.drawable.ic_chevron_24),
// tint = TangemTheme.colors.icon.primary1,
// contentDescription = null,
// )
// }
// }
//
// @Composable
// private fun DropdownSelector(
// isExpanded: Boolean,
// onDismiss: () -> Unit,
// onItemClick: (ApproveType) -> Unit,
// items: ImmutableList<ApproveType>,
// ) {
// DropdownMenu(
// expanded = isExpanded,
// onDismissRequest = onDismiss,
// modifier = Modifier
// .wrapContentSize()
// .background(TangemTheme.colors.background.secondary),
// ) {
// items.forEach { item ->
// DropdownMenuItem(
// onClick = {
// onItemClick.invoke(item)
// },
// ) {
// Text(
// text = getTitleForApproveType(approveType = item),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.body1,
// maxLines = 1,
// )
// }
// }
// }
// }
//
// @Composable
// private fun FeeItem(fee: String) {
// InformationItem(
// subtitle = stringResource(id = R.string.send_fee_label),
// value = fee,
// )
// }
//
// @Composable
// private fun SubtitleItem(subtitle: String, modifier: Modifier = Modifier) {
// Text(
// modifier = modifier,
// text = subtitle,
// color = TangemTheme.colors.text.secondary,
// style = TangemTheme.typography.body2,
// )
// }
//
// @Composable
// private fun getTitleForApproveType(approveType: ApproveType): String = when (approveType) {
// ApproveType.LIMITED -> stringResource(id = R.string.swapping_permission_current_transaction)
// ApproveType.UNLIMITED -> stringResource(id = R.string.swapping_permission_unlimited)
// }
//
// // region preview
//
// @Preview
// @Composable
// private fun Preview_AgreementBottomSheet_InLightTheme() {
// TangemTheme(isDark = false) {
// SwapPermissionBottomSheetContent(data = previewData) {}
// }
// }
//
// @Preview
// @Composable
// private fun Preview_AgreementBottomSheet_InDarkTheme() {
// TangemTheme(isDark = true) {
// SwapPermissionBottomSheetContent(data = previewData) {}
// }
// }
//
// private val previewData = SwapPermissionState.ReadyForRequest(
// currency = "DAI",
// amount = "∞",
// walletAddress = "",
// spenderAddress = "",
// fee = TextReference.Str("2,14$"),
// approveType = ApproveType.UNLIMITED,
// approveButton = ApprovePermissionButton(true) {},
// cancelButton = CancelPermissionButton(true),
// onChangeApproveType = { ApproveType.UNLIMITED },
// )
//endregion preview

View file

@ -1,99 +1,44 @@
package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.SwapPermissionState
import com.tangem.feature.swap.models.SwapStateHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig
import com.tangem.feature.swap.presentation.R
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SwapScreen(stateHolder: SwapStateHolder) {
val coroutineScope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
skipHalfExpanded = true,
)
BackHandler(
onBack = {
if (bottomSheetState.isVisible) {
hideBottomSheet(coroutineScope, stateHolder, bottomSheetState)
} else {
stateHolder.onBackClicked()
}
},
)
BackHandler(onBack = stateHolder.onBackClicked)
LaunchedEffect(bottomSheetState.targetValue) {
if (bottomSheetState.targetValue == ModalBottomSheetValue.Hidden) {
stateHolder.onCancelPermissionBottomSheet.invoke()
Scaffold(
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
SwapScreenContent(
state = stateHolder,
modifier = Modifier.padding(scaffoldPaddings),
)
stateHolder.bottomSheetConfig?.let { config ->
when (config.content) {
is GivePermissionBottomSheetConfig -> {
SwapPermissionBottomSheet(config = config)
}
is ChooseProviderBottomSheetConfig -> {
ChooseProviderBottomSheet(config = config)
}
}
}
}
ModalBottomSheetLayout(
modifier = Modifier.systemBarsPadding(),
sheetContent = {
if (stateHolder.permissionState is SwapPermissionState.ReadyForRequest) {
SwapPermissionBottomSheetContent(
data = stateHolder.permissionState,
onCancel = {
hideBottomSheet(coroutineScope, stateHolder, bottomSheetState)
},
)
} else {
// Required "else" block to prevent compose crash
// always close BS if its empty, cause user should not see this
LaunchedEffect(Unit) {
coroutineScope.launch { bottomSheetState.hide() }
}
Box(modifier = Modifier.fillMaxSize())
}
},
sheetState = bottomSheetState,
sheetShape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
),
sheetElevation = TangemTheme.dimens.elevation24,
scrimColor = TangemTheme.colors.background.secondary.copy(alpha = 0.4f),
content = {
SwapScreenContent(
state = stateHolder,
onPermissionWarningClick = {
val isBottomSheetReady = !bottomSheetState.isVisible &&
stateHolder.permissionState is SwapPermissionState.ReadyForRequest
coroutineScope.launch {
if (isBottomSheetReady) {
bottomSheetState.show()
stateHolder.onShowPermissionBottomSheet.invoke()
} else {
bottomSheetState.hide()
}
}
},
)
},
)
}
@OptIn(ExperimentalMaterialApi::class)
private fun hideBottomSheet(
coroutineScope: CoroutineScope,
stateHolder: SwapStateHolder,
bottomSheetState: ModalBottomSheetState,
) {
coroutineScope.launch {
stateHolder.onCancelPermissionBottomSheet.invoke()
bottomSheetState.hide()
}
}

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
@ -39,30 +38,21 @@ import java.math.BigDecimal
@Suppress("LongMethod")
@Composable
internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: () -> Unit) {
internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) {
val keyboard by keyboardAsState()
Box(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.secondary),
) {
Image(
modifier = Modifier
.size(width = TangemTheme.dimens.size164, height = TangemTheme.dimens.size80)
.align(Alignment.BottomCenter)
.padding(bottom = TangemTheme.dimens.spacing28),
painter = painterResource(id = R.drawable.ill_one_inch_powered),
contentDescription = null,
contentScale = ContentScale.Fit,
)
Column {
AppBarWithBackButton(
text = stringResource(R.string.common_swap),
onBackClick = state.onBackClicked,
iconRes = R.drawable.ic_close_24,
)
Column(
modifier = Modifier
.fillMaxWidth()
@ -98,7 +88,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick:
},
)
}
MainButton(state = state, onPermissionWarningClick = onPermissionWarningClick)
MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet)
}
}
@ -486,7 +476,7 @@ private val state = SwapStateHolder(
@Composable
private fun SwapScreenContentPreview() {
TangemTheme(isDark = false) {
SwapScreenContent(state = state) {}
SwapScreenContent(state = state, modifier = Modifier)
}
}

View file

@ -133,7 +133,6 @@ internal class SwapViewModel @Inject constructor(
fun setRouter(router: SwapRouter) {
swapRouter = router
uiState = uiState.copy(
onBackClicked = router::back,
onSelectTokenClick = {
router.openScreen(SwapNavScreen.SelectToken)
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened)
@ -260,31 +259,7 @@ internal class SwapViewModel @Inject constructor(
},
onSuccess = { providersState ->
val (provider, state) = updateLoadedQuotes(providersState)
when (state) {
is SwapState.QuotesLoadedState -> {
fillDataState(state.permissionState, state.swapDataModel)
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = state,
fromToken = fromToken.currency,
swapProvider = provider,
) { updatedFee ->
dataState = dataState.copy(
selectedFee = updatedFee,
)
}
}
is SwapState.EmptyAmountState -> {
uiState = stateBuilder.createQuotesEmptyAmountState(
uiStateHolder = uiState,
emptyAmountState = state,
)
}
is SwapState.SwapError -> {
Timber.e("SwapError when loading quotes ${state.error}")
uiState = stateBuilder.mapError(uiState, state.error) { startLoadingQuotesFromLastState() }
}
}
setupLoadedState(provider, state, fromToken)
},
onError = {
Timber.e("Error when loading quotes: $it")
@ -293,6 +268,34 @@ internal class SwapViewModel @Inject constructor(
)
}
private fun setupLoadedState(provider: SwapProvider, state: SwapState, fromToken: CryptoCurrencyStatus) {
when (state) {
is SwapState.QuotesLoadedState -> {
fillDataState(state.permissionState, state.swapDataModel)
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = state,
fromToken = fromToken.currency,
swapProvider = provider,
) { updatedFee ->
dataState = dataState.copy(
selectedFee = updatedFee,
)
}
}
is SwapState.EmptyAmountState -> {
uiState = stateBuilder.createQuotesEmptyAmountState(
uiStateHolder = uiState,
emptyAmountState = state,
)
}
is SwapState.SwapError -> {
Timber.e("SwapError when loading quotes ${state.error}")
uiState = stateBuilder.mapError(uiState, state.error) { startLoadingQuotesFromLastState() }
}
}
}
private fun updateLoadedQuotes(state: Map<SwapProvider, SwapState>): Pair<SwapProvider, SwapState> {
val selectedSwapProvider = dataState.selectedProvider ?: state.keys.first()
dataState = dataState.copy(
@ -563,11 +566,20 @@ internal class SwapViewModel @Inject constructor(
onChangeCardsClicked()
analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked)
},
onBackClicked = { onSearchEntered("") },
onBackClicked = {
val bottomSheet = uiState.bottomSheetConfig
if (bottomSheet != null && bottomSheet.isShow) {
uiState = stateBuilder.dismissBottomSheet(uiState)
} else {
swapRouter.back()
}
onSearchEntered("")
},
onMaxAmountSelected = { onMaxAmountClicked() },
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
uiState = stateBuilder.showPermissionBottomSheet(uiState) { stateBuilder.dismissBottomSheet(uiState) }
},
hidePermissionBottomSheet = {
startLoadingQuotesFromLastState()
@ -597,9 +609,38 @@ internal class SwapViewModel @Inject constructor(
},
onClickFee = {},
onSelectFeeType = {},
onProviderClick = {
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = it,
providersStates = dataState.lastLoadedSwapStates,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
onProviderSelect = {
val provider = findAndSelectProvider(it)
val swapState = dataState.lastLoadedSwapStates[provider]
val fromToken = dataState.fromCryptoCurrency
if (provider != null && swapState != null && fromToken != null) {
setupLoadedState(
provider = provider,
state = swapState,
fromToken = fromToken,
)
}
},
)
}
private fun findAndSelectProvider(providerId: String): SwapProvider? {
val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId }
if (selectedProvider != null) {
dataState = dataState.copy(
selectedProvider = selectedProvider,
)
}
return selectedProvider
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->