Updated on 2026-08-14

This commit is contained in:
Tangem 2024-12-24 17:36:59 +03:00
commit fbb99bcda0
317 changed files with 3800 additions and 2894 deletions

View file

@ -60,7 +60,6 @@ dependencies {
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.compose.constraintLayout)
implementation(deps.compose.accompanist.systemUiController)
/** Api */
implementation(projects.features.swap.api)
@ -71,7 +70,6 @@ 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)

View file

@ -3,9 +3,9 @@ package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
@ -14,12 +14,15 @@ import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.events.SwapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class SwapStateHolder(
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val blockchainId: String, // not the same as networkId, its local id in app
val warnings: List<SwapWarning> = emptyList(),
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
val isInsufficientFunds: Boolean,
val event: StateEvent<SwapEvent> = consumedEvent(),
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
val providerState: ProviderState,
@ -106,35 +109,6 @@ data class LegalState(
val onClick: (String) -> Unit,
)
sealed interface SwapWarning {
data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning
data object InsufficientFunds : SwapWarning
data class NoAvailableTokensToSwap(val notificationConfig: NotificationConfig) : SwapWarning
data class GenericWarning(
val title: TextReference? = null,
val message: TextReference? = null,
val onClick: () -> Unit,
) : SwapWarning
data class GeneralError(val notificationConfig: NotificationConfig) : SwapWarning
data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning
data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning
data class NeedReserveToCreateAccount(val notificationConfig: NotificationConfig) : SwapWarning
data class ReduceAmount(val notificationConfig: NotificationConfig) : SwapWarning
sealed interface Cardano : SwapWarning {
val notificationConfig: NotificationConfig
data class MinAdaValueCharged(override val notificationConfig: NotificationConfig) : Cardano
data class InsufficientBalanceToTransferCoin(override val notificationConfig: NotificationConfig) : Cardano
data class InsufficientBalanceToTransferToken(override val notificationConfig: NotificationConfig) : Cardano
}
}
enum class ChangeCardsButtonState {
ENABLED, DISABLED, UPDATE_IN_PROGRESS
}

View file

@ -4,6 +4,7 @@ import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class UiActions(
val onSearchEntered: (String) -> Unit,
@ -15,8 +16,8 @@ data class UiActions(
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceAmount: (SwapAmount) -> Unit,
val onLeaveExistentialDeposit: (SwapAmount) -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions

View file

@ -0,0 +1,205 @@
package com.tangem.feature.swap.models.states
import com.tangem.common.ui.R
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.utils.getExpressErrorMessage
import com.tangem.feature.swap.utils.getExpressErrorTitle
internal object SwapNotificationUM {
sealed class Error(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.ic_alert_24,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM.Error(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonState = buttonState,
onCloseClick = onCloseClick,
) {
data class GenericError(
val title: TextReference = resourceReference(id = R.string.common_warning),
val subtitle: TextReference?,
val onConfirmClick: () -> Unit,
) : Error(
title = title,
subtitle = subtitle ?: resourceReference(id = R.string.common_unknown_error),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.common_ok),
onClick = onConfirmClick,
),
)
data object ApprovalInProgressWarning : Error(
title = resourceReference(R.string.warning_express_approval_in_progress_title),
subtitle = resourceReference(R.string.warning_express_approval_in_progress_message),
)
data class TransactionInProgressWarning(
val currencySymbol: String,
) : Error(
title = resourceReference(R.string.warning_express_active_transaction_title),
subtitle = resourceReference(
id = R.string.warning_express_active_transaction_message,
formatArgs = wrappedList(currencySymbol),
),
)
data class UnableToCoverFeeWarning(
val fromToken: CryptoCurrency,
val currencyName: String,
val currencySymbol: String,
val feeCurrency: CryptoCurrency?,
val onConfirmClick: (CryptoCurrency) -> Unit,
) : Error(
title = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_title,
wrappedList(fromToken.network.name),
),
subtitle = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_description,
wrappedList(currencyName, currencySymbol),
),
iconResId = fromToken.networkIconResId,
buttonState = feeCurrency?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
onClick = { onConfirmClick(it) },
)
},
)
data class MinimalAmountError(
val amount: String,
) : Error(
title = resourceReference(
id = R.string.warning_express_too_minimal_amount_title,
formatArgs = wrappedList(amount),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
)
data class MaximumAmountError(
val amount: String,
) : Error(
title = resourceReference(
id = R.string.warning_express_too_maximum_amount_title,
formatArgs = wrappedList(amount),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
)
}
sealed class Warning(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.img_attention_20,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM.Warning(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
) {
data class NoAvailableTokensToSwap(
val tokenName: String,
) : Warning(
title = resourceReference(
com.tangem.feature.swap.presentation.R.string.warning_express_no_exchangeable_coins_title,
),
subtitle = resourceReference(
id = com.tangem.feature.swap.presentation.R.string.warning_express_no_exchangeable_coins_description,
formatArgs = wrappedList(tokenName),
),
)
data class NeedReserveToCreateAccount(
val amount: String,
val token: String,
) : Warning(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
)
data class ReduceAmount(
val currencyName: String,
val amount: String,
val onConfirmClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(
R.string.send_notification_high_fee_text,
wrappedList(currencyName, amount),
),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(
R.string.xtz_withdrawal_message_reduce,
wrappedList(amount),
),
onClick = onConfirmClick,
),
)
data class ExpressError(
val expressDataError: ExpressDataError,
val onConfirmClick: () -> Unit,
) : Warning(
title = getExpressErrorTitle(expressDataError),
subtitle = getExpressErrorMessage(expressDataError),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onConfirmClick,
),
)
data class ExpressGeneralError(
val code: Int,
val onConfirmClick: () -> Unit,
) : Warning(
title = TextReference.Res(R.string.warning_express_refresh_required_title),
subtitle = TextReference.Res(R.string.express_error_code, wrappedList(code)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = TextReference.Res(R.string.warning_button_refresh),
onClick = onConfirmClick,
),
)
}
sealed class Info(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.ic_alert_circle_24,
) : NotificationUM.Info(
title = title,
subtitle = subtitle,
iconResId = iconResId,
) {
data class PermissionNeeded(
val providerName: String,
val fromTokenSymbol: String,
) : Info(
title = resourceReference(R.string.express_provider_permission_needed),
subtitle = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromTokenSymbol),
),
iconResId = R.drawable.ic_locked_24,
)
}
}

View file

@ -1,5 +0,0 @@
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

@ -6,6 +6,4 @@ import com.tangem.common.ui.alerts.models.AlertUM
@Immutable
internal sealed class SwapEvent {
data class ShowAlert(val alert: AlertUM) : SwapEvent()
data class ShowShareDialog(val txUrl: String) : SwapEvent()
}

View file

@ -0,0 +1,345 @@
package com.tangem.feature.swap.presentation
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class SwapNotificationsFactory(
private val actions: UiActions,
) {
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
return persistentListOf(
SwapNotificationUM.Warning.ExpressGeneralError(
code = code,
onConfirmClick = onRefreshClick,
),
)
}
fun getGeneralErrorStateNotifications(
notifications: ImmutableList<NotificationUM>,
message: TextReference?,
onClick: () -> Unit,
): ImmutableList<NotificationUM> {
val updatedNotifications = notifications.toMutableList()
updatedNotifications.add(
SwapNotificationUM.Error.GenericError(
subtitle = message,
onConfirmClick = onClick,
),
)
return updatedNotifications.toPersistentList()
}
fun getNotAvailableStateNotifications(fromCurrencyName: String): ImmutableList<NotificationUM> {
return persistentListOf(
SwapNotificationUM.Warning.NoAvailableTokensToSwap(fromCurrencyName),
)
}
fun getQuotesErrorStateNotifications(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,
feeItem: FeeItemState,
includeFeeInAmount: IncludeFeeInAmount,
): ImmutableList<NotificationUM> {
return buildList {
add(getWarningForError(expressDataError, fromToken, actions.onRetryClick))
if (includeFeeInAmount is IncludeFeeInAmount.Included && feeItem is FeeItemState.Content) {
add(
NotificationUM.Warning.FeeCoverageNotification(
feeItem.amountCrypto,
feeItem.amountFiatFormatted,
),
)
}
}.toPersistentList()
}
fun getApprovalInProgressStateNotification(
notifications: ImmutableList<NotificationUM>,
): ImmutableList<NotificationUM> {
val updatedNotifications = notifications
.filterNot { it is SwapNotificationUM.Info.PermissionNeeded }
.toMutableList()
updatedNotifications.add(0, SwapNotificationUM.Error.ApprovalInProgressWarning)
return updatedNotifications.toPersistentList()
}
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
): ImmutableList<NotificationUM> {
val warnings = buildList {
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken)
maybeAddTransactionInProgressWarning(quoteModel)
}
return warnings.toPersistentList()
}
private fun MutableList<NotificationUM>.maybeAddTransactionInProgressWarning(
quoteModel: SwapState.QuotesLoadedState,
) {
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
add(SwapNotificationUM.Error.ApprovalInProgressWarning)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
add(
SwapNotificationUM.Error.TransactionInProgressWarning(
currencySymbol = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.network.currencySymbol,
),
)
}
}
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
) {
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
val amount = quoteModel.fromTokenInfo.tokenAmount
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
includeFeeInAmount.amountSubtractFee
} else {
amount
}
val fee = when (val feeState = quoteModel.txFee) {
TxFeeState.Empty -> null
is TxFeeState.MultipleFeeState -> if (feeState.normalFee.feeType == selectedFeeType) {
feeState.normalFee
} else {
feeState.priorityFee
}
is TxFeeState.SingleFeeState -> feeState.fee
}
val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.id.value)
// blockchain specific
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.feeValue.orZero(),
sendingAmount = amount.value,
cryptoCurrencyStatus = fromCurrencyStatus,
onReduceClick = { reduceBy, reduceByDiff, _ ->
actions.onReduceByAmount(
amount.copy(value = amount.value.minus(reduceByDiff)),
reduceBy,
)
},
)
addValidateTransactionNotifications(
dustValue = quoteModel.currencyCheck?.dustValue.orZero(),
validationError = quoteModel.validationResult,
cryptoCurrency = fromCurrencyStatus.currency,
minAdaValue = quoteModel.minAdaValue,
onReduceClick = { reduceTo, _ ->
actions.onReduceToAmount(amount.copy(value = reduceTo))
},
)
if (!isCardano) {
addDustWarningNotification(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.feeValue.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addReserveAmountErrorNotification(
reserveAmount = quoteModel.currencyCheck?.reserveAmount,
sendingAmount = amountToRequest.value,
cryptoCurrency = fromCurrencyStatus.currency,
isAccountFunded = false,
)
addReduceAmountNotification(
cryptoCurrencyStatus = fromCurrencyStatus,
fromAmount = quoteModel.fromTokenInfo.tokenAmount,
onReduceByAmount = actions.onReduceByAmount,
)
addTransactionLimitErrorNotification(
utxoLimit = quoteModel.currencyCheck?.utxoAmountLimit,
cryptoCurrency = fromCurrencyStatus.currency,
onReduceClick = { reduceTo, _ ->
actions.onReduceToAmount(amountToRequest.copy(value = reduceTo))
},
)
}
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
val currencyTo = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals),
currencyTo.symbol,
),
)
}
}
}
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
providerName: String,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
add(
SwapNotificationUM.Info.PermissionNeeded(
providerName = fromToken.symbol,
fromTokenSymbol = providerName,
),
)
}
}
private fun MutableList<NotificationUM>.maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
selectedFeeType: FeeType,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
if (needShowNetworkFeeCoverageWarningShow(quoteModel)) {
add(
NotificationUM.Warning.FeeCoverageNotification(
fee.feeCryptoFormattedWithNative,
fee.feeFiatFormattedWithNative,
),
)
}
}
else -> Unit
}
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
is TxFeeState.MultipleFeeState -> when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
}
}
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
) {
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
if (needShowCoverWarning) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(
fromToken = fromToken,
feeCurrency = feeEnoughState.feeCurrency,
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
onConfirmClick = actions.onBuyClick,
),
)
}
}
private fun MutableList<NotificationUM>.addReduceAmountNotification(
cryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: SwapAmount,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
) {
val isTezos = isTezos(cryptoCurrencyStatus.currency.network.id.value)
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val threshold = getTezosThreshold()
val isTotalBalance = fromAmount.value >= balance && balance > threshold
if (isTezos && isTotalBalance) {
add(
SwapNotificationUM.Warning.ReduceAmount(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
val patchedAmount = fromAmount.copy(
value = fromAmount.value - threshold,
)
onReduceByAmount(patchedAmount, threshold)
},
),
)
}
}
private fun getWarningForError(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,
onRetryClick: () -> Unit,
): NotificationUM {
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> SwapNotificationUM.Error.MinimalAmountError(
expressDataError.amount.value.format {
crypto(fromToken.symbol, fromToken.decimals)
},
)
is ExpressDataError.ExchangeTooBigAmountError -> SwapNotificationUM.Error.MaximumAmountError(
expressDataError.amount.value.format {
crypto(fromToken.symbol, fromToken.decimals)
},
)
else -> SwapNotificationUM.Warning.ExpressError(
expressDataError,
onConfirmClick = onRetryClick,
)
}
}
private fun needShowNetworkFeeCoverageWarningShow(quoteModel: SwapState.QuotesLoadedState): Boolean {
return quoteModel.currencyCheck?.existentialDeposit == null
}
}

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
@ -17,10 +16,7 @@ import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.domain.models.ui.FeeType
@ -69,7 +65,7 @@ private fun ChooseFeeBottomSheetContent(content: ChooseFeeBottomSheetConfig) {
@Composable
private fun FooterBlock(readMore: TextReference, readMoreUrl: String, onReadMoreClick: (String) -> Unit) {
val linkText = readMore.resolveReference()
val fullString = stringResource(R.string.common_fee_selector_footer, linkText)
val fullString = stringResourceSafe(R.string.common_fee_selector_footer, linkText)
val linkTextPosition = fullString.length - linkText.length
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {

View file

@ -12,13 +12,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
@ -43,7 +43,7 @@ fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.express_choose_providers_subtitle),
text = stringResourceSafe(R.string.express_choose_providers_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
@ -89,7 +89,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
tint = TangemTheme.colors.icon.informative,
)
Text(
text = stringResource(R.string.express_more_providers_soon),
text = stringResourceSafe(R.string.express_more_providers_soon),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.icon.informative,
modifier = Modifier

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -25,6 +24,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.GRAY_SCALE_ALPHA
@ -122,7 +122,7 @@ private fun ProviderContentState(
Row {
if (state.namePrefix == ProviderState.PrefixType.PROVIDED_BY) {
Text(
text = stringResource(id = R.string.express_by_provider),
text = stringResourceSafe(id = R.string.express_by_provider),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
@ -262,7 +262,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxWidth()) {
Column {
Text(
text = stringResource(R.string.express_provider),
text = stringResourceSafe(R.string.express_provider),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
@ -282,7 +282,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
strokeWidth = TangemTheme.dimens.size2,
)
Text(
text = stringResource(R.string.express_fetch_best_rates),
text = stringResourceSafe(R.string.express_fetch_best_rates),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
@ -357,7 +357,7 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = stringResource(R.string.express_provider_best_rate),
text = stringResourceSafe(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
@ -374,7 +374,7 @@ private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = stringResource(id = R.string.express_provider_permission_needed),
text = stringResourceSafe(id = R.string.express_provider_permission_needed),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
@ -391,7 +391,7 @@ private fun RecommendedItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = stringResource(id = R.string.express_provider_recommended),
text = stringResourceSafe(id = R.string.express_provider_recommended),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),

View file

@ -4,13 +4,16 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
@ -20,21 +23,24 @@ import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
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.NetworkInfo
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.models.states.events.SwapEvent
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.presentation.SwapNotificationsFactory
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.feature.swap.utils.getExpressErrorMessage
import com.tangem.feature.swap.utils.getExpressErrorTitle
import com.tangem.feature.swap.viewmodels.SwapProcessDataState
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.PERCENT
import com.tangem.utils.StringsSigns.TILDE_SIGN
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
@ -60,6 +66,10 @@ internal class StateBuilder(
appCurrencyProvider = appCurrencyProvider,
)
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(actions)
}
fun createInitialLoadingState(
initialCurrencyFrom: CryptoCurrency,
initialCurrencyTo: CryptoCurrency?,
@ -110,6 +120,7 @@ internal class StateBuilder(
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
priceImpact = PriceImpact.Empty(),
isInsufficientFunds = false,
)
}
@ -141,18 +152,7 @@ internal class StateBuilder(
),
canSelectAnotherToken = true,
),
warnings = listOf(
SwapWarning.NoAvailableTokensToSwap(
notificationConfig = NotificationConfig(
title = resourceReference(R.string.warning_express_no_exchangeable_coins_title),
subtitle = resourceReference(
id = R.string.warning_express_no_exchangeable_coins_description,
formatArgs = wrappedList(fromToken.currency.name),
),
iconResId = R.drawable.img_attention_20,
),
),
),
notifications = notificationsFactory.getNotAvailableStateNotifications(fromToken.currency.name),
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
@ -211,7 +211,7 @@ internal class StateBuilder(
networkIconRes = getActiveIconRes(toToken.network.id.value),
isBalanceHidden = isBalanceHiddenProvider(),
),
warnings = emptyList(),
notifications = persistentListOf(),
fee = FeeItemState.Empty,
swapButton = SwapButton(enabled = false, onClick = {}),
providerState = ProviderState.Loading(),
@ -235,6 +235,7 @@ internal class StateBuilder(
uiStateHolder: SwapStateHolder,
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
swapProvider: SwapProvider,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
@ -243,9 +244,10 @@ internal class StateBuilder(
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = getWarningsForSuccessState(
val notifications = notificationsFactory.getConfirmationStateNotifications(
quoteModel = quoteModel,
fromToken = fromToken,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
selectedFeeType = selectedFeeType,
providerName = swapProvider.name,
)
@ -304,7 +306,8 @@ internal class StateBuilder(
balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
),
warnings = warnings,
isInsufficientFunds = isInsufficientFundsCondition(quoteModel),
notifications = notifications,
permissionState = convertPermissionState(
lastPermissionState = uiStateHolder.permissionState,
permissionDataState = quoteModel.permissionState,
@ -314,7 +317,7 @@ internal class StateBuilder(
),
fee = feeState,
swapButton = SwapButton(
enabled = getSwapButtonEnabled(quoteModel),
enabled = getSwapButtonEnabled(notifications),
onClick = actions.onSwapClick,
),
changeCardsButtonState = if (isReverseSwapPossible) {
@ -363,281 +366,17 @@ internal class StateBuilder(
)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
selectedFeeType: FeeType,
providerName: String,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
return warnings
}
private fun maybeAddTransactionInProgressWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
warnings.add(
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
warnings.add(
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.warning_express_active_transaction_title),
description = resourceReference(
id = R.string.warning_express_active_transaction_message,
formatArgs = wrappedList(
quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.network.currencySymbol,
),
),
),
)
}
}
private fun maybeAddDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList<SwapWarning>) {
quoteModel.warnings.forEach {
when (it) {
is Warning.ExistentialDepositWarning -> {
warnings.add(createLeaveExistentialDepositWarning(quoteModel, it))
}
is Warning.MinAmountWarning -> {
warnings.add(
SwapWarning.GeneralError(
NotificationConfig(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.warning_express_dust_message,
wrappedList(
it.dustValue.toPlainString(),
it.dustValue.toPlainString(),
),
),
iconResId = R.drawable.ic_alert_circle_24,
),
),
)
}
is Warning.ReduceAmountWarning -> {
warnings.add(
SwapWarning.ReduceAmount(
notificationConfig = createReduceAmountNotificationConfig(
currencyName = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.name,
amount = it.tezosFeeThreshold.toPlainString(),
onConfirmClick = {
val fromAmount = quoteModel.fromTokenInfo.tokenAmount
val patchedAmount = fromAmount.copy(
value = fromAmount.value - it.tezosFeeThreshold,
)
actions.onReduceAmount(patchedAmount)
},
),
),
)
}
Warning.Cardano.InsufficientBalanceToTransferCoin -> {
warnings.add(createInsufficientBalanceToTransferCoin())
}
is Warning.Cardano.InsufficientBalanceToTransferToken -> {
warnings.add(createInsufficientBalanceToTransferToken(tokenName = it.tokenName))
}
is Warning.Cardano.MinAdaValueCharged -> {
warnings.add(createMinAdaValueCharged(minAdaValue = it.minAdaValue, tokenName = it.tokenName))
}
}
}
}
private fun maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
warnings.add(
SwapWarning.NeedReserveToCreateAccount(
notificationConfig = createActivateAccountNotificationConfig(
status.amountToCreateAccount,
quoteModel.toTokenInfo.cryptoCurrencyStatus.currency.symbol,
),
),
)
}
}
}
private fun createLeaveExistentialDepositWarning(
quoteModel: SwapState.QuotesLoadedState,
domainWarning: Warning.ExistentialDepositWarning,
): SwapWarning {
val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency
val deposit = domainWarning.existentialDeposit.format { crypto(fromCurrency).uncapped() }
return SwapWarning.GeneralError(
NotificationConfig(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(
R.string.send_notification_existential_deposit_text,
wrappedList(deposit),
),
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.common_ok),
onClick = {
actions.onLeaveExistentialDeposit(
SwapAmount(
domainWarning.minAvailableAmount,
fromCurrency.decimals,
),
)
},
),
),
)
}
private fun maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
fromToken: CryptoCurrency,
providerName: String,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol, providerName),
),
)
}
}
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
selectedFeeType: FeeType,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
if (needShowNetworkFeeCoverageWarningShow(quoteModel)) {
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(
fee.feeCryptoFormattedWithNative,
fee.feeFiatFormattedWithNative,
),
),
)
}
}
else -> Unit
}
}
private fun needShowNetworkFeeCoverageWarningShow(quoteModel: SwapState.QuotesLoadedState): Boolean {
return quoteModel.warnings.none { it is Warning.ExistentialDepositWarning }
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
is TxFeeState.MultipleFeeState -> when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
}
}
private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
warnings: MutableList<SwapWarning>,
) {
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
if (needShowCoverWarning) {
warnings.add(
SwapWarning.UnableToCoverFeeWarning(
createUnableToCoverFeeNotificationConfig(
fromToken = fromToken,
feeCurrency = feeEnoughState.feeCurrency,
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
),
),
)
}
}
private fun maybeAddInsufficientFundsWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (isInsufficientFundsCondition(quoteModel)) {
warnings.add(SwapWarning.InsufficientFunds)
}
}
private fun isInsufficientFundsCondition(quoteModel: SwapState.QuotesLoadedState): Boolean {
return !quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
}
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
return false
}
}
val preparedSwapConfigState = quoteModel.preparedSwapConfigState
// check has has outgoing transaction
if (preparedSwapConfigState.hasOutgoingTransaction) return false
// check has MinAmountWarning warning
val hasCriticalWarning = quoteModel.warnings.any {
it is Warning.MinAmountWarning ||
it is Warning.Cardano.InsufficientBalanceToTransferCoin ||
it is Warning.Cardano.InsufficientBalanceToTransferToken ||
it is Warning.ExistentialDepositWarning
}
if (hasCriticalWarning) return false
return when (preparedSwapConfigState.includeFeeInAmount) {
IncludeFeeInAmount.BalanceNotEnough -> false
IncludeFeeInAmount.Excluded ->
preparedSwapConfigState.isAllowedToSpend &&
preparedSwapConfigState.isBalanceEnough &&
preparedSwapConfigState.feeState is SwapFeeState.Enough
is IncludeFeeInAmount.Included -> true
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>): Boolean {
return notifications.none {
it is SwapNotificationUM.Error || it is NotificationUM.Error ||
it is SwapNotificationUM.Warning.ExpressError || it is SwapNotificationUM.Warning.ExpressGeneralError ||
it is SwapNotificationUM.Warning.NoAvailableTokensToSwap ||
it is SwapNotificationUM.Warning.NeedReserveToCreateAccount
}
}
@ -653,15 +392,13 @@ internal class StateBuilder(
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(expressDataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
uiStateHolder.fee.amountCrypto,
uiStateHolder.fee.amountFiatFormatted,
)
warnings.add(SwapWarning.GeneralWarning(feeCoverageNotification))
}
val notifications = notificationsFactory.getQuotesErrorStateNotifications(
expressDataError = expressDataError,
fromToken = fromToken.cryptoCurrencyStatus.currency,
feeItem = uiStateHolder.fee,
includeFeeInAmount = includeFeeInAmount,
)
val providerState = getProviderStateForError(
swapProvider = swapProvider,
fromToken = fromToken.cryptoCurrencyStatus.currency,
@ -700,7 +437,7 @@ internal class StateBuilder(
balance = fromToken.cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
),
receiveCardData = receiveCardData,
warnings = warnings,
notifications = notifications,
permissionState = GiveTxPermissionState.Empty,
fee = FeeItemState.Empty,
swapButton = SwapButton(
@ -754,57 +491,6 @@ internal class StateBuilder(
}
}
private fun getWarningForError(expressDataError: ExpressDataError, fromToken: CryptoCurrency): SwapWarning {
val providerErrorMessage = getExpressErrorMessage(expressDataError)
val providerErrorTitle = getExpressErrorTitle(expressDataError)
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
title = resourceReference(
id = R.string.warning_express_too_minimal_amount_title,
formatArgs = wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
),
)
is ExpressDataError.ExchangeTooBigAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
title = resourceReference(
id = R.string.warning_express_too_maximum_amount_title,
formatArgs = wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
),
)
is ExpressDataError.ProviderDifferentAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
title = resourceReference(id = R.string.common_error),
subtitle = resourceReference(
R.string.express_error_provider_amount_roundup,
formatArgs = wrappedList(
expressDataError.code,
expressDataError.fromProviderAmount.format { simple(decimals = expressDataError.decimals) },
),
),
iconResId = R.drawable.ic_alert_circle_24,
),
)
else -> SwapWarning.GeneralWarning(
notificationConfig = NotificationConfig(
title = providerErrorTitle,
subtitle = providerErrorMessage,
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = actions.onRetryClick,
),
),
)
}
}
fun createQuotesEmptyAmountState(
uiStateHolder: SwapStateHolder,
emptyAmountState: SwapState.EmptyAmountState,
@ -843,7 +529,8 @@ internal class StateBuilder(
balance = toTokenStatus?.getFormattedAmount(isNeedSymbol = false) ?: DASH_SIGN,
isBalanceHidden = isBalanceHiddenProvider(),
),
warnings = emptyList(),
notifications = persistentListOf(),
isInsufficientFunds = false,
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
@ -1002,19 +689,8 @@ internal class StateBuilder(
fun createInitialErrorState(uiState: SwapStateHolder, code: Int, onRefreshClick: () -> Unit): SwapStateHolder {
return uiState.copy(
warnings = listOf(
SwapWarning.GeneralWarning(
notificationConfig = NotificationConfig(
title = TextReference.Res(R.string.warning_express_refresh_required_title),
subtitle = TextReference.Res(R.string.express_error_code, wrappedList(code)),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = TextReference.Res(R.string.warning_button_refresh),
onClick = onRefreshClick,
),
),
),
),
isInsufficientFunds = false,
notifications = notificationsFactory.getInitialErrorStateNotifications(code, onRefreshClick),
)
}
@ -1051,17 +727,9 @@ internal class StateBuilder(
}
fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder {
val warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded }.toMutableList()
warnings.add(
0,
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
return uiState.copy(
permissionState = GiveTxPermissionState.InProgress,
warnings = warnings,
notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications),
)
}
@ -1211,17 +879,14 @@ internal class StateBuilder(
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder = uiState.copy(event = consumedEvent())
fun addWarning(uiState: SwapStateHolder, message: TextReference?, onClick: () -> Unit): SwapStateHolder {
val renewWarnings = uiState.warnings.toMutableList()
renewWarnings.add(
SwapWarning.GenericWarning(
fun addNotification(uiState: SwapStateHolder, message: TextReference?, onClick: () -> Unit): SwapStateHolder {
return uiState.copy(
notifications = notificationsFactory.getGeneralErrorStateNotifications(
notifications = uiState.notifications,
message = message,
onClick = onClick,
),
)
return uiState.copy(
warnings = renewWarnings,
)
}
private fun convertPermissionState(
@ -1272,17 +937,6 @@ 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 GiveTxPermissionState.ReadyForRequest) {
@ -1368,21 +1022,6 @@ internal class StateBuilder(
}
}
fun updateSelectedProvider(uiState: SwapStateHolder, selectedProviderId: String): SwapStateHolder {
val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig
return if (config != null) {
uiState.copy(
bottomSheetConfig = uiState.bottomSheetConfig.copy(
content = config.copy(
selectedProviderId = selectedProviderId,
),
),
)
} else {
uiState
}
}
fun showSelectFeeBottomSheet(
uiState: SwapStateHolder,
selectedFee: FeeType,
@ -1420,21 +1059,6 @@ internal class StateBuilder(
}
}
fun updateSelectedFeeBottomSheet(uiState: SwapStateHolder, selectedFee: FeeType): SwapStateHolder {
val config = uiState.bottomSheetConfig?.content as? ChooseFeeBottomSheetConfig
return if (config != null) {
uiState.copy(
bottomSheetConfig = uiState.bottomSheetConfig.copy(
content = config.copy(
selectedFee = selectedFee,
),
),
)
} else {
uiState
}
}
private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList<FeeItemState.Content> {
return listOf(
FeeItemState.Content(
@ -1483,122 +1107,6 @@ internal class StateBuilder(
}
}
// region warnings
private fun createPermissionNotificationConfig(fromTokenSymbol: String, providerName: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.express_provider_permission_needed),
subtitle = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromTokenSymbol),
),
iconResId = R.drawable.ic_locked_24,
)
}
private fun createActivateAccountNotificationConfig(amount: BigDecimal, token: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
iconResId = R.drawable.img_attention_20,
)
}
private fun createReduceAmountNotificationConfig(
currencyName: String,
amount: String,
onConfirmClick: () -> Unit,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.xtz_withdrawal_message_reduce, wrappedList(amount)),
onClick = onConfirmClick,
),
)
}
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
feeCurrency: CryptoCurrency?,
currencyName: String,
currencySymbol: String,
): NotificationConfig {
val buttonState = feeCurrency?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
onClick = { actions.onBuyClick(it) },
)
}
return NotificationConfig(
title = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_title,
wrappedList(fromToken.network.name),
),
subtitle = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_description,
wrappedList(currencyName, currencySymbol),
),
iconResId = fromToken.networkIconResId,
buttonsState = buttonState,
)
}
private fun createNetworkFeeCoverageNotificationConfig(
cryptoAmount: String,
fiatAmount: String,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
iconResId = R.drawable.img_attention_20,
)
}
private fun createMinAdaValueCharged(minAdaValue: String, tokenName: String): SwapWarning {
return SwapWarning.Cardano.MinAdaValueCharged(
NotificationConfig(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
iconResId = R.drawable.img_attention_20,
),
)
}
private fun createInsufficientBalanceToTransferCoin(): SwapWarning {
return SwapWarning.Cardano.InsufficientBalanceToTransferCoin(
NotificationConfig(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
iconResId = R.drawable.ic_alert_circle_24,
),
)
}
private fun createInsufficientBalanceToTransferToken(tokenName: String): SwapWarning {
return SwapWarning.Cardano.InsufficientBalanceToTransferToken(
NotificationConfig(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
iconResId = R.drawable.ic_alert_circle_24,
),
)
}
// end region
private fun getShortAddressValue(fullAddress: String): String {
check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" }
val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH)
@ -1712,14 +1220,6 @@ internal class StateBuilder(
return amount.format { crypto(symbol, currency.decimals) }
}
@Suppress("UnusedPrivateMember")
private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String {
val fiatAmount = value.fiatAmount ?: return DASH_SIGN
val appCurrency = appCurrencyProvider()
return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol)
}
private fun getFormattedFiatAmount(amount: BigDecimal?): String {
val appCurrency = appCurrencyProvider()
@ -1735,10 +1235,6 @@ internal class StateBuilder(
return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
}
private fun toBigDecimalOrNull(text: String): BigDecimal? {
return text.replace(",", ".").toBigDecimalOrNull()
}
private fun getLocaleName(): String {
return if (Locale.getDefault().language == "ru") {
RU_LOCALE

View file

@ -1,22 +1,19 @@
package com.tangem.feature.swap.ui
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.feature.swap.models.states.events.SwapEvent
import com.tangem.feature.swap.presentation.R
@Composable
internal fun SwapEventEffect(event: StateEvent<SwapEvent>) {
val context = LocalContext.current
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
@ -35,9 +32,6 @@ internal fun SwapEventEffect(event: StateEvent<SwapEvent>) {
is SwapEvent.ShowAlert -> {
alertConfig = value.alert
}
is SwapEvent.ShowShareDialog -> {
context.shareText(value.txUrl)
}
}
},
)
@ -53,7 +47,7 @@ internal fun SwapAlert(state: AlertUM, onDismiss: () -> Unit) {
},
)
val dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
title = stringResourceSafe(id = R.string.common_cancel),
onClick = onDismiss,
)

View file

@ -6,16 +6,15 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
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.WebViewBottomSheetConfig
import com.tangem.feature.swap.presentation.R
@Composable
@ -26,7 +25,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
modifier = Modifier.systemBarsPadding(),
topBar = {
AppBarWithBackButton(
text = stringResource(R.string.common_swap),
text = stringResourceSafe(R.string.common_swap),
onBackClick = stateHolder.onBackClicked,
iconRes = R.drawable.ic_close_24,
)
@ -40,20 +39,13 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
modifier = Modifier.padding(scaffoldPaddings),
)
stateHolder.bottomSheetConfig?.let { config ->
if (stateHolder.bottomSheetConfig != null) {
val config = stateHolder.bottomSheetConfig
when (config.content) {
is GiveTxPermissionBottomSheetConfig -> {
GiveTxPermissionBottomSheet(config = config)
}
is ChooseProviderBottomSheetConfig -> {
ChooseProviderBottomSheet(config = config)
}
is ChooseFeeBottomSheetConfig -> {
ChooseFeeBottomSheet(config = config)
}
is WebViewBottomSheetConfig -> {
WebViewBottomSheet(config = config)
}
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config)
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
}
}
}

View file

@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
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
@ -24,13 +23,10 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.getActiveIconResByCoinId
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.domain.models.ui.FeeType
@ -38,7 +34,9 @@ import com.tangem.feature.swap.domain.models.ui.PriceImpact
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.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
@ -69,7 +67,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
FeeItemBlock(state = state.fee)
if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings)
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet)
@ -84,7 +82,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
if (state.shouldShowMaxAmount && keyboard is Keyboard.Opened) {
Text(
text = stringResource(id = R.string.send_max_amount_label),
text = stringResourceSafe(id = R.string.send_max_amount_label),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
@ -206,7 +204,7 @@ private fun getAnnotatedStringForLegalsWithClick(
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 fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
val tosIndex = fullString.indexOf(tosTitle)
val policyIndex = fullString.indexOf(policyTitle)
val string = buildAnnotatedString {
@ -238,7 +236,7 @@ private fun getAnnotatedStringForLegalsWithClick(
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 fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legal)
val legalIndex = fullString.indexOf(legalTitle)
val string = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
@ -302,69 +300,27 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun SwapWarnings(warnings: List<SwapWarning>) {
private fun SwapNotifications(notifications: List<NotificationUM>) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxWidth(),
) {
warnings.forEach { warning ->
when (warning) {
is SwapWarning.PermissionNeeded -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.GenericWarning -> {
val message = warning.message?.resolveReference()
?: stringResource(id = R.string.common_unknown_error)
notifications.forEach { notification ->
when (notification) {
is SwapNotificationUM.Error.GenericError -> {
RefreshableWarningCard(
title = stringResource(id = R.string.common_warning),
description = message,
onClick = warning.onClick,
title = notification.title.resolveReference(),
description = notification.config.subtitle.resolveReference(),
onClick = notification.onConfirmClick,
)
}
is SwapWarning.NoAvailableTokensToSwap -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.GeneralError -> {
Notification(
config = warning.notificationConfig,
iconTint = TangemTheme.colors.icon.warning,
)
}
is SwapWarning.UnableToCoverFeeWarning -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.GeneralWarning -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.GeneralInformational -> {
Notification(
config = warning.notificationConfig,
iconTint = TangemTheme.colors.icon.accent,
)
}
is SwapWarning.NeedReserveToCreateAccount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.ReduceAmount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.TransactionInProgressWarning -> {
is SwapNotificationUM.Error.ApprovalInProgressWarning,
is SwapNotificationUM.Error.TransactionInProgressWarning,
-> {
CardWithIcon(
title = warning.title.resolveReference(),
description = warning.description.resolveReference(),
title = notification.config.title?.resolveReference().orEmpty(),
description = notification.config.subtitle.resolveReference(),
icon = {
CircularProgressIndicator(
modifier = Modifier
@ -375,8 +331,20 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
},
)
}
is SwapWarning.Cardano -> Notification(config = warning.notificationConfig)
SwapWarning.InsufficientFunds -> Unit
else -> {
Notification(
config = notification.config,
iconTint = when (notification) {
is SwapNotificationUM.Error.UnableToCoverFeeWarning,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Error.ExceedsBalance,
is NotificationUM.Info,
is NotificationUM.Warning,
-> null
is NotificationUM.Error -> TangemTheme.colors.icon.warning
},
)
}
}
SpacerH8()
}
@ -387,18 +355,18 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> Unit) {
// order is important
when {
state.warnings.any { it is SwapWarning.InsufficientFunds } -> {
state.isInsufficientFunds -> {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.swapping_insufficient_funds),
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
enabled = false,
onClick = state.swapButton.onClick,
)
}
state.warnings.any { it is SwapWarning.PermissionNeeded } -> {
state.notifications.any { it is SwapNotificationUM.Info.PermissionNeeded } -> {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.give_permission_title),
text = stringResourceSafe(id = R.string.give_permission_title),
enabled = true,
onClick = onPermissionWarningClick,
)
@ -406,7 +374,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
else -> {
PrimaryButtonIconEnd(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.swapping_swap_action),
text = stringResourceSafe(id = R.string.swapping_swap_action),
iconResId = R.drawable.ic_tangem_24,
enabled = state.swapButton.enabled,
onClick = state.swapButton.onClick,
@ -459,21 +427,12 @@ private val state = SwapStateHolder(
isClickable = true,
onClick = {},
),
warnings = listOf(
SwapWarning.PermissionNeeded(
notificationConfig = NotificationConfig(
title = stringReference("Give Permission"),
subtitle = stringReference("To continue swapping you need to give permission to Tangem"),
iconResId = R.drawable.ic_locked_24,
),
),
SwapWarning.NoAvailableTokensToSwap(
notificationConfig = NotificationConfig(
title = stringReference("No tokens"),
subtitle = stringReference("Swap tokens not available"),
iconResId = R.drawable.img_attention_20,
),
notifications = persistentListOf(
SwapNotificationUM.Info.PermissionNeeded(
providerName = "Provider",
fromTokenSymbol = "POL",
),
SwapNotificationUM.Warning.NoAvailableTokensToSwap("POLYGON"),
),
swapButton = SwapButton(enabled = true, onClick = {}),
onRefresh = {},
@ -484,6 +443,7 @@ private val state = SwapStateHolder(
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
shouldShowMaxAmount = true,
isInsufficientFunds = false,
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
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.SpacerH12
@ -27,6 +26,7 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
@ -60,12 +60,12 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
},
topBar = {
ExpandableSearchView(
title = stringResource(R.string.swapping_token_list_title),
title = stringResourceSafe(R.string.swapping_token_list_title),
onBackClick = onBack,
placeholderSearchText = stringResource(id = R.string.common_search_tokens),
placeholderSearchText = stringResourceSafe(id = R.string.common_search_tokens),
onSearchChange = state.onSearchEntered,
onSearchDisplayClose = { state.onSearchEntered("") },
subtitle = stringResource(id = R.string.express_exchange_token_list_subtitle),
subtitle = stringResourceSafe(id = R.string.express_exchange_token_list_subtitle),
)
},
)
@ -92,7 +92,7 @@ private fun EmptyTokensList(modifier: Modifier = Modifier) {
.padding(top = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.CenterHorizontally),
text = stringResource(id = R.string.exchange_tokens_empty_tokens),
text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
@ -113,7 +113,7 @@ private fun TokensNotFound(modifier: Modifier = Modifier) {
.padding(top = TangemTheme.dimens.spacing32)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.TopCenter),
text = stringResource(id = R.string.express_token_list_empty_search),
text = stringResourceSafe(id = R.string.express_token_list_empty_search),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,

View file

@ -8,7 +8,6 @@ import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
@ -19,6 +18,7 @@ import com.tangem.core.ui.components.inputrow.InputRowImage
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -135,7 +135,7 @@ private fun SwapSuccessScreenButtons(
if (txUrl.isNotBlank()) {
Row {
SecondaryButtonIconStart(
text = stringResource(id = R.string.common_explore),
text = stringResourceSafe(id = R.string.common_explore),
iconResId = R.drawable.ic_web_24,
onClick = onExploreClick,
modifier = Modifier.weight(1f),
@ -143,7 +143,7 @@ private fun SwapSuccessScreenButtons(
if (showStatusButton) {
SpacerW12()
SecondaryButtonIconStart(
text = stringResource(id = R.string.express_cex_status_button_title),
text = stringResourceSafe(id = R.string.express_cex_status_button_title),
iconResId = R.drawable.ic_arrow_top_right_24,
onClick = onStatusClick,
modifier = Modifier.weight(1f),
@ -153,7 +153,7 @@ private fun SwapSuccessScreenButtons(
SpacerH12()
}
PrimaryButton(
text = stringResource(id = textRes),
text = stringResourceSafe(id = textRes),
enabled = true,
onClick = onDoneClick,
modifier = Modifier.fillMaxWidth(),

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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
@ -38,6 +37,7 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
@ -74,7 +74,7 @@ fun TransactionCard(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
Header(balance = stringResource(R.string.common_balance, balance), type = type)
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
Content(
type = type,
@ -134,7 +134,7 @@ fun TransactionCardEmpty(
horizontalAlignment = Alignment.Start,
) {
Header(
balance = stringResource(id = R.string.swapping_token_not_available),
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
type = type,
)

View file

@ -1,70 +0,0 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
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.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.models.states.WebViewBottomSheetConfig
@Composable
fun WebViewBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(
config = config,
containerColor = 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
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_WebViewBottomSheetContent() {
TangemThemePreview {
WebViewBottomSheetContent(
content = WebViewBottomSheetConfig(
url = "https://tangem.com/en/",
),
)
}
}

View file

@ -7,13 +7,16 @@ import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class SwapProcessDataState(
// Initial network id
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val approveDataModel: RequestApproveStateData? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee? = null,

View file

@ -25,10 +25,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
@ -44,8 +41,8 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.SwapWarning
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
@ -81,6 +78,7 @@ internal class SwapViewModel @Inject constructor(
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
@ -142,6 +140,7 @@ internal class SwapViewModel @Inject constructor(
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed = false
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
private var swapRouter: SwapRouter by Delegates.notNull()
private val isUserResolvableError: (SwapState) -> Boolean = {
@ -310,6 +309,7 @@ internal class SwapViewModel @Inject constructor(
fromToken = fromCurrencyStatus,
toToken = toCurrencyStatus,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
)
}
@ -327,6 +327,7 @@ internal class SwapViewModel @Inject constructor(
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
isSilent: Boolean = false,
) {
@ -345,6 +346,7 @@ internal class SwapViewModel @Inject constructor(
fromToken = fromToken,
toToken = toToken,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
toProvidersList = toProvidersList,
),
)
@ -360,6 +362,7 @@ internal class SwapViewModel @Inject constructor(
toToken = toCurrency,
amount = amount,
isSilent = isSilent,
reduceBalanceBy = dataState.reduceBalanceBy,
toProvidersList = findSwapProviders(fromCurrency, toCurrency),
)
}
@ -369,6 +372,7 @@ internal class SwapViewModel @Inject constructor(
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
): PeriodicTask<Map<SwapProvider, SwapState>> {
return PeriodicTask(
@ -378,6 +382,7 @@ internal class SwapViewModel @Inject constructor(
runCatching(dispatchers.io) {
dataState = dataState.copy(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
swapDataModel = null,
approveDataModel = null,
)
@ -386,6 +391,7 @@ internal class SwapViewModel @Inject constructor(
toToken = toToken,
providers = toProvidersList,
amountToSwap = amount,
reduceBalanceBy = reduceBalanceBy,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
}
@ -408,7 +414,7 @@ internal class SwapViewModel @Inject constructor(
},
onError = {
Timber.e("Error when loading quotes: $it")
uiState = stateBuilder.addWarning(uiState, null) { startLoadingQuotesFromLastState() }
uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() }
},
)
}
@ -423,13 +429,14 @@ internal class SwapViewModel @Inject constructor(
uiStateHolder = uiState,
quoteModel = state,
fromToken = fromToken.currency,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
swapProvider = provider,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
isReverseSwapPossible = isReverseSwapPossible(),
)
if (uiState.warnings.any { it is SwapWarning.UnableToCoverFeeWarning }) {
if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) {
analyticsEventHandler.send(
SwapEvents.NoticeNotEnoughFee(
token = initialCurrencyFrom.symbol,
@ -822,6 +829,7 @@ internal class SwapViewModel @Inject constructor(
fromToken = fromToken,
toToken = toToken,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
)
swapRouter.openScreen(SwapNavScreen.Main)
@ -846,6 +854,13 @@ internal class SwapViewModel @Inject constructor(
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = it,
).getOrNull() ?: it,
)
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
@ -884,6 +899,7 @@ internal class SwapViewModel @Inject constructor(
).getOrNull()
val decimals = newFromToken.currency.decimals
lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value)
lastReducedBalanceBy.value = BigDecimal.ZERO
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountFormatted = inputNumberFormatter.formatWithThousands(lastAmount.value, decimals),
@ -895,13 +911,18 @@ internal class SwapViewModel @Inject constructor(
fromToken = newFromToken,
toToken = newToToken,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(newFromToken, newToToken),
)
}
}
}
private fun onAmountChanged(value: String) {
private fun onAmountChanged(
value: String,
forceQuotesUpdate: Boolean = false,
reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
) {
viewModelScope.launch {
val fromToken = dataState.fromCryptoCurrency
val toToken = dataState.toCryptoCurrency
@ -913,6 +934,7 @@ internal class SwapViewModel @Inject constructor(
fromToken,
).getOrNull()
lastAmount.value = cutValue
lastReducedBalanceBy.value = reduceBalanceBy
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals),
@ -926,11 +948,12 @@ internal class SwapViewModel @Inject constructor(
isAmountChangedByUser = true
}
amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) {
amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) {
startLoadingQuotes(
fromToken = fromToken,
toToken = toToken,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
)
}
@ -946,12 +969,12 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun onReduceAmountClicked(newAmount: SwapAmount) {
onAmountChanged(newAmount.formatToUIRepresentation())
}
private fun onLeaveExistentialDepositClicked(newAmount: SwapAmount) {
onAmountChanged(newAmount.formatToUIRepresentation())
private fun onReduceAmountClicked(newAmount: SwapAmount, reduceBalanceBy: BigDecimal = BigDecimal.ZERO) {
onAmountChanged(
value = newAmount.formatToUIRepresentation(),
forceQuotesUpdate = true,
reduceBalanceBy = reduceBalanceBy,
)
}
private fun onAmountSelected(selected: Boolean) {
@ -1012,8 +1035,8 @@ internal class SwapViewModel @Inject constructor(
onSearchEntered("")
},
onMaxAmountSelected = ::onMaxAmountClicked,
onReduceAmount = ::onReduceAmountClicked,
onLeaveExistentialDeposit = ::onLeaveExistentialDepositClicked,
onReduceToAmount = ::onReduceAmountClicked,
onReduceByAmount = ::onReduceAmountClicked,
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
@ -1040,20 +1063,10 @@ internal class SwapViewModel @Inject constructor(
}
},
onSelectFeeType = {
val state = dataState.getCurrentLoadedSwapState() ?: return@UiActions
val fromToken = dataState.fromCryptoCurrency ?: return@UiActions
val amountToSwap = dataState.amount ?: return@UiActions
val selectedProvider = dataState.selectedProvider ?: return@UiActions
uiState = stateBuilder.dismissBottomSheet(uiState)
dataState = dataState.copy(selectedFee = it)
viewModelScope.launch(dispatchers.io) {
val updatedState = swapInteractor.updateQuotesStateWithSelectedFee(
state = state,
selectedFee = it.feeType,
fromToken = fromToken,
amountToSwap = amountToSwap,
)
setupLoadedState(selectedProvider, updatedState, fromToken)
startLoadingQuotesFromLastState(false)
}
},
onProviderClick = { providerId ->