Updated on 2026-08-14

This commit is contained in:
Tangem 2024-05-16 17:37:13 +03:00
commit bfc4bcb30f
55 changed files with 637 additions and 183 deletions

View file

@ -7,4 +7,7 @@ interface SendFeatureToggles {
/** Availability of redesigned send screen */
val isRedesignedSendEnabled: Boolean
/** Updates remote toggle */
suspend fun fetchNewSendEnabled()
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
/** Common */
implementation(projects.common)

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,7 +20,15 @@ internal object SendFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles {
return DefaultSendFeatureToggles(featureTogglesManager = featureTogglesManager)
fun provideSendFeatureToggles(
featureTogglesManager: FeatureTogglesManager,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): SendFeatureToggles {
return DefaultSendFeatureToggles(
featureTogglesManager = featureTogglesManager,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
)
}
}

View file

@ -1,16 +1,41 @@
package com.tangem.features.send.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of Send feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property tangemTechApi api to get remote feature toggle for send
* @property dispatchers coroutine dispatchers
*/
internal class DefaultSendFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SendFeatureToggles {
private val remoteSendEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val isRedesignedSendEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") &&
remoteSendEnabled.value
override suspend fun fetchNewSendEnabled() {
runCatching(dispatchers.io) {
tangemTechApi.getFeatures().getOrThrow()
}.onSuccess { response ->
remoteSendEnabled.update { response.isNewSendEnabled }
}.onFailure {
Timber.e(it.localizedMessage, "Unable to fetch new send toggle")
}
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
@ -66,9 +68,9 @@ internal sealed class SendAnalyticEvents(
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */
data class SelectedFee(val feeType: SelectedFeeType) : SendAnalyticEvents(
data class SelectedFee(val feeType: AnalyticsParam.FeeType) : SendAnalyticEvents(
event = "Fee Selected",
params = mapOf("Fee Type" to feeType.name),
params = mapOf("Fee Type" to feeType.value),
)
/** Custom fee selected */
@ -97,7 +99,16 @@ internal sealed class SendAnalyticEvents(
// region Transaction Result
/** Transaction send screen opened */
data object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened")
data class TransactionScreenOpened(
val token: String,
val feeType: AnalyticsParam.FeeType,
) : SendAnalyticEvents(
event = "Transaction Sent Screen Opened",
params = mapOf(
TOKEN to token,
FEE_TYPE to feeType.value,
),
)
/** Share button clicked */
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
@ -145,12 +156,4 @@ internal enum class EnterAddressSource {
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}
internal enum class SelectedFeeType {
Min,
Max,
Fixed,
Normal,
Custom,
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SelectedFeeType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -11,11 +13,13 @@ import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.Provider
internal class SendScreenAnalyticSender(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyProvider: Provider<CryptoCurrency>,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun send(prevScreen: SendUiStateType, state: SendUiState) {
@ -73,16 +77,57 @@ internal class SendScreenAnalyticSender(
)
}
fun sendTransaction() {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val cryptoCurrency = cryptoCurrencyProvider()
val feeState = state.getFeeState(isEditState) ?: return
val recipientState = state.getRecipientState(isEditState) ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val feeType = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(
SendAnalyticEvents.TransactionScreenOpened(
token = cryptoCurrency.symbol,
feeType = feeType,
),
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Send(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = feeType,
),
memoType = getSendTransactionMemoType(recipientState.memoTextField),
),
)
}
private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) {
val type = when (feeSelectorState.fees) {
is TransactionFee.Single -> SelectedFeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> SelectedFeeType.Min
FeeType.Market -> SelectedFeeType.Normal
FeeType.Fast -> SelectedFeeType.Max
FeeType.Custom -> SelectedFeeType.Custom
}
}
val type = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type))
}
private fun getSendTransactionFeeType(feeSelectorState: FeeSelectorState.Content): AnalyticsParam.FeeType =
when (feeSelectorState.fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> AnalyticsParam.FeeType.Min
FeeType.Market -> AnalyticsParam.FeeType.Normal
FeeType.Fast -> AnalyticsParam.FeeType.Max
FeeType.Custom -> AnalyticsParam.FeeType.Custom
}
}
private fun getSendTransactionMemoType(
recipientMemo: SendTextField.RecipientMemo?,
): Basic.TransactionSent.MemoType {
val memo = recipientMemo?.value
return when {
memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty
memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full
else -> Basic.TransactionSent.MemoType.Null
}
}
}

View file

@ -1,15 +1,18 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.compose.runtime.Immutable
import com.tangem.domain.wallets.models.UserWalletId
/**
* Available wallet to send
*
* @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address
*/
@Immutable
data class AvailableWallet(
val name: String,
val userWalletId: UserWalletId,
val address: String,
)

View file

@ -54,7 +54,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)),
text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
@ -98,7 +98,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_existential_deposit_button, wrappedList(deposit)),
text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)),
onClick = onConfirmClick,
),
)
@ -119,12 +119,13 @@ internal sealed class SendNotification(val config: NotificationConfig) {
),
) {
data class HighFeeError(
val currencyName: String,
val amount: String,
val onConfirmClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
@ -156,7 +157,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(
R.string.send_network_fee_warning_content,
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
)

View file

@ -66,11 +66,12 @@ internal class SendNotificationFactory(
amountValue = amountValue,
feeValue = feeValue,
)
val sendingAmount = calculateSubtractedAmount(
isFeeCoverage = isFeeCoverage,
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isFeeCoverage,
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = sendState.reduceAmountBy,
)
buildList {
// errors
@ -194,7 +195,7 @@ internal class SendNotificationFactory(
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
feeAmount + receivedAmount
receivedAmount
}
val currencyDeposit = currencyChecksRepository.getExistentialDeposit(
userWalletId,
@ -260,6 +261,7 @@ internal class SendNotificationFactory(
if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add(
SendNotification.Warning.HighFeeError(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
clickIntents.onAmountReduceClick(

View file

@ -54,7 +54,7 @@ internal fun checkFeeCoverage(
/**
* Calculates subtracted amount
*/
internal fun calculateSubtractedAmount(
private fun calculateSubtractedAmount(
isFeeCoverage: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,

View file

@ -47,7 +47,7 @@ internal class BitcoinCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
footer = resourceReference(R.string.send_bitcoin_custom_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,

View file

@ -43,7 +43,7 @@ internal class EthereumCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
footer = resourceReference(R.string.send_evm_custom_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,

View file

@ -10,6 +10,7 @@ import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.RoundingMode
internal class SendAmountFieldMaxAmountConverter(
private val stateRouterProvider: Provider<StateRouter>,
@ -33,7 +34,7 @@ internal class SendAmountFieldMaxAmountConverter(
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty()
return state.copyWrapped(
isEditState = isEditState,
amountState = amountState.copy(

View file

@ -26,12 +26,15 @@ import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.utils.getFiatFormatted
@Composable
internal fun SendNavigationButtons(
@ -172,21 +175,27 @@ private fun SendingText(
}
if (feeFiat != null && sendingFiat != null) {
val sendingValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = sendingFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val sendingValue = getFiatFormatted(
value = sendingFiat,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val feeValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val feeValue = getFiatFormatted(
value = feeState.fee?.amount?.value,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val textResource = remember(sendingValue, feeValue) {
resourceReference(
id = R.string.send_summary_transaction_description,
formatArgs = wrappedList(sendingValue, feeValue),
)
}
Text(
text = stringResource(id = R.string.send_summary_transaction_description, sendingValue, feeValue),
text = textResource.resolveAnnotatedReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),

View file

@ -33,7 +33,10 @@ import kotlinx.coroutines.flow.withIndex
@Composable
internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val snackbarHostState = remember { SnackbarHostState() }
BackHandler(onBack = uiState.clickIntents::onBackClick)
val onBackClick = uiState.clickIntents::onBackClick.takeIf {
uiState.sendState?.isSending != true
} ?: {}
BackHandler(onBack = onBackClick)
Column(
modifier = Modifier
.fillMaxSize()

View file

@ -15,6 +15,7 @@ internal fun LazyListScope.notifications(
notifications: ImmutableList<SendNotification>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
) {
itemsIndexed(
items = notifications,
@ -44,6 +45,7 @@ internal fun LazyListScope.notifications(
-> null
is SendNotification.Error -> TangemTheme.colors.icon.warning
},
isEnabled = !isClickDisabled,
)
},
)

View file

@ -16,6 +16,7 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@ -114,11 +115,14 @@ private fun FeeError(feeSelectorState: FeeSelectorState) {
private fun FeeSelectorState.Content.getAmount(feeType: FeeType): Amount? {
val choosableFees = fees as? TransactionFee.Choosable
val decimals = fees.normal.amount.decimals
val customValue = this.customValues.firstOrNull()?.value?.parseToBigDecimal(decimals)
val customAmount = fees.normal.amount.copy(value = customValue)
return when (feeType) {
FeeType.Slow -> choosableFees?.minimum?.amount
FeeType.Market -> fees.normal.amount
FeeType.Fast -> choosableFees?.priority?.amount
FeeType.Custom -> null
FeeType.Custom -> customAmount
}
}

View file

@ -33,9 +33,10 @@ internal fun TextFieldWithPaste(
) {
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.disabled
isReadOnly -> label to TangemTheme.colors.text.tertiary
else -> label to TangemTheme.colors.text.secondary
}
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) {
Box(
modifier = Modifier
@ -59,6 +60,7 @@ internal fun TextFieldWithPaste(
SimpleTextField(
value = value,
placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
modifier = Modifier

View file

@ -24,7 +24,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.AmountStateP
@Composable
internal fun AmountBlock(
amountState: SendStates.AmountState,
isSuccess: Boolean,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
@ -53,7 +53,7 @@ internal fun AmountBlock(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16,
@ -91,7 +91,7 @@ private fun AmountBlockPreview_Light(
TangemTheme {
AmountBlock(
amountState = value,
isSuccess = false,
isClickDisabled = false,
isEditingDisabled = false,
onClick = {},
)
@ -106,7 +106,7 @@ private fun AmountBlockPreview_Dark(
TangemTheme(isDark = true) {
AmountBlock(
amountState = value,
isSuccess = true,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)

View file

@ -26,13 +26,13 @@ import com.tangem.features.send.impl.presentation.utils.getCryptoReference
import com.tangem.features.send.impl.presentation.utils.getFiatReference
@Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick: () -> Unit) {
internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess, onClick = onClick)
.clickable(enabled = !isClickDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
@ -115,7 +115,7 @@ private fun FeeBlockPreview_Light(@PreviewParameter(FeeBlockPreviewProvider::cla
TangemTheme {
FeeBlock(
feeState = value,
isSuccess = true,
isClickDisabled = true,
onClick = {},
)
}
@ -127,7 +127,7 @@ private fun FeeBlockPreview_Dark(@PreviewParameter(FeeBlockPreviewProvider::clas
TangemTheme(isDark = true) {
FeeBlock(
feeState = value,
isSuccess = true,
isClickDisabled = true,
onClick = {},
)
}

View file

@ -23,7 +23,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.RecipientSta
@Composable
internal fun RecipientBlock(
recipientState: SendStates.RecipientState,
isSuccess: Boolean,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
@ -38,7 +38,7 @@ internal fun RecipientBlock(
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
AddressBlock(recipientState.addressTextField)
@ -104,7 +104,7 @@ private fun RecipientBlockPreview_Light(
TangemTheme {
RecipientBlock(
recipientState = value,
isSuccess = true,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)
@ -119,7 +119,7 @@ private fun RecipientBlockPreview_Dark(
TangemTheme(isDark = true) {
RecipientBlock(
recipientState = value,
isSuccess = true,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)

View file

@ -35,12 +35,13 @@ private const val TAP_HELP_ANIMATION_DELAY = 500L
@Composable
internal fun SendContent(uiState: SendUiState) {
val sendState = uiState.sendState ?: return
val isClickDisabled = sendState.isSending || sendState.isSuccess
LazyColumn(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
blocks(uiState)
tapHelp(isDisplay = sendState.showTapHelp)
notifications(sendState.notifications)
notifications(notifications = sendState.notifications, isClickDisabled = isClickDisabled)
}
}
@ -50,6 +51,7 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
val feeState = uiState.feeState ?: return
val sendState = uiState.sendState ?: return
val isSuccess = sendState.isSuccess
val isClickDisabled = sendState.isSending || isSuccess
val timestamp = sendState.transactionDate
item(key = BLOCKS_KEY) {
@ -65,19 +67,19 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
}
RecipientBlock(
recipientState = recipientState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showRecipient,
)
AmountBlock(
amountState = amountState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showAmount,
)
FeeBlock(
feeState = feeState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
onClick = uiState.clickIntents::showFee,
)
}

View file

@ -11,6 +11,7 @@ import java.math.BigDecimal
import java.math.RoundingMode
private const val FIAT_DECIMALS = 2
private const val CRYPTO_FEE_DECIMALS = 6
private const val FEE_MINIMUM_VALUE = 0.01
internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? {
@ -21,7 +22,7 @@ internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): Tex
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amount.value,
cryptoCurrency = amount.currencySymbol,
decimals = amount.decimals,
decimals = amount.decimals.coerceAtMost(CRYPTO_FEE_DECIMALS),
),
),
)
@ -36,24 +37,27 @@ internal fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency
internal fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String {
if (value == null || rate == null) return EMPTY_BALANCE_SIGN
val feeValue = value.multiply(rate)
val scaled = feeValue.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
val formattedValue = if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
return getFiatFormatted(feeValue, appCurrency.code, appCurrency.symbol)
}
internal fun getFiatFormatted(value: BigDecimal?, currencyCode: String, currencySymbol: String): String {
val scaled = value?.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
return if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
buildString {
append(BigDecimalFormatter.CAN_BE_LOWER_SIGN)
append(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = BigDecimal(FEE_MINIMUM_VALUE),
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
),
)
}
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatAmount = value,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
)
}
return formattedValue
}

View file

@ -26,7 +26,6 @@ import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError
@ -59,8 +58,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import java.util.Locale
@ -79,8 +79,8 @@ internal class SendViewModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusesSyncUseCase: GetCryptoCurrencyStatusesSyncUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
@ -95,6 +95,7 @@ internal class SendViewModel @Inject constructor(
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
@ -180,6 +181,7 @@ internal class SendViewModel @Inject constructor(
stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState },
analyticsEventHandler = analyticsEventHandler,
cryptoCurrencyProvider = Provider { cryptoCurrency },
)
}
@ -188,6 +190,7 @@ internal class SendViewModel @Inject constructor(
private set
private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList()
private var isAmountSubtractAvailable: Boolean = false
private var isTapHelpPreviewEnabled: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -205,7 +208,6 @@ internal class SendViewModel @Inject constructor(
private var sendIdleTimer = 0L
init {
subscribeOnQRScannerResult()
subscribeOnCurrencyStatusUpdates()
subscribeOnBalanceHidden()
getTapHelpPreviewAvailability()
@ -372,7 +374,7 @@ internal class SendViewModel @Inject constructor(
cryptoCurrencyStatus = currencyStatus
coinCryptoCurrencyStatus = coinCurrencyStatus
feeCryptoCurrencyStatus = feeCurrencyStatus
subscribeOnQRScannerResult()
when {
uiState.sendState?.isSuccess == true -> {
stateRouter.showSend()
@ -400,57 +402,48 @@ internal class SendViewModel @Inject constructor(
}
private fun getUserWallets() {
getWalletsUseCase()
.conflate()
.distinctUntilChanged()
.onEach { userWallets ->
coroutineScope {
runCatching {
userWallets
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) { wallet.toAvailableWallet() }
}.awaitAll()
}.onSuccess { result ->
uiState = stateFactory.onLoadedWalletsList(wallets = result)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
viewModelScope.launch(dispatchers.main) {
runCatching {
getWalletsUseCase.invokeSync()
?.toAvailableWallets()
.orEmpty()
}.onSuccess { result ->
combine(*result.toTypedArray()) { it }
.onEach {
userWallets = it.filterNotNull().toList()
uiState = stateFactory.onLoadedWalletsList(wallets = userWallets)
}
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}
private suspend fun UserWallet.toAvailableWallet(): AvailableWallet? {
return if (!isMultiCurrency) {
val status = getCryptoCurrencyStatusSyncUseCase(walletId).getOrNull()
val address = status?.value?.networkAddress.takeIf {
status?.currency?.network?.id == cryptoCurrency.network.id &&
status.currency.network.derivationPath !is Network.DerivationPath.Custom
}
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
}
} else {
val statuses = getCryptoCurrencyStatusesSyncUseCase(walletId).getOrNull()
val walletCurrency = statuses?.firstOrNull {
it.currency.network.id == cryptoCurrency.network.id &&
it.currency.network.derivationPath !is Network.DerivationPath.Custom
}
val address = walletCurrency?.value?.networkAddress
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
}
}
}
private suspend fun List<UserWallet>.toAvailableWallets(): List<Flow<AvailableWallet?>> =
filterNot { it.walletId == userWalletId || it.isLocked }
.mapNotNull { wallet ->
val status = if (!wallet.isMultiCurrency) {
getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let {
if (it.network.id == cryptoCurrency.network.id) {
getNetworkAddressesUseCase(wallet.walletId, it.network)
} else {
null
}
}
} else {
getNetworkAddressesUseCase(wallet.walletId, cryptoCurrency.network)
}
status?.map { address ->
AvailableWallet(
name = wallet.name,
address = address,
userWalletId = wallet.walletId,
)
}
}
private suspend fun getTxHistory() {
val txHistoryList = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
@ -637,7 +630,7 @@ internal class SendViewModel @Inject constructor(
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {
private suspend fun validateAddress(value: String): Boolean = runCatching {
val isValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
@ -647,7 +640,7 @@ internal class SendViewModel @Inject constructor(
?.any { it.value == value } ?: true
onEnteredValidAddress(isValidAddress, isAddressInWallet)
return isValidAddress
}
}.getOrElse { false }
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
@ -875,11 +868,25 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getSendingStateUpdate(isSending = false)
updateTransactionStatus(txData)
scheduleBalanceUpdate()
analyticsEventHandler.send(SendAnalyticEvents.TransactionScreenOpened)
addTokenToWalletIfNeeded()
sendScreenAnalyticSender.sendTransaction()
},
)
}
private fun addTokenToWalletIfNeeded() {
if (cryptoCurrency !is CryptoCurrency.Token) return
val recipientState = uiState.getRecipientState(stateRouter.isEditState) ?: return
val destinationAddress = recipientState.addressTextField.value
val maybeUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
viewModelScope.launch(dispatchers.io) {
addCryptoCurrenciesUseCase(userWalletId = maybeUserWallet.userWalletId, currency = cryptoCurrency)
}
}
private suspend fun updateTransactionStatus(txData: TransactionData) {
val txUrl = getExplorerTransactionUrlUseCase(
userWalletId = userWalletId,

View file

@ -218,6 +218,7 @@ internal class StateBuilder(
quoteModel = quoteModel,
fromToken = fromToken,
ignoreAmountReduce = uiStateHolder.reduceAmountIgnore,
selectedFeeType = selectedFeeType,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
@ -317,12 +318,13 @@ internal class StateBuilder(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
ignoreAmountReduce: Boolean,
selectedFeeType: FeeType,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings, ignoreAmountReduce)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
@ -461,18 +463,37 @@ internal class StateBuilder(
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
selectedFeeType: FeeType,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
createNetworkFeeCoverageNotificationConfig(
quoteModel.fromTokenInfo.tokenAmount.getFormattedCryptoAmount(
quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency,
),
fee.feeFiatFormatted,
),
),
)
}
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 maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
@ -548,12 +569,12 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included) {
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
fromToken.tokenAmount.getFormattedCryptoAmount(fromToken.cryptoCurrencyStatus.currency),
uiStateHolder.fee.amountFiatFormatted,
)
warnings.add(SwapWarning.GeneralWarning(feeCoverageNotification))
}
val providerState = getProviderStateForError(
swapProvider = swapProvider,
@ -1397,10 +1418,16 @@ internal class StateBuilder(
)
}
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig {
private fun createNetworkFeeCoverageNotificationConfig(
cryptoAmount: String,
fiatAmount: String,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(R.string.swapping_network_fee_warning_content),
subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
iconResId = R.drawable.img_attention_20,
)
}

View file

@ -42,6 +42,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown,
-> null
}
}

View file

@ -155,7 +155,11 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
class NetworksNoAccount(val network: String, val symbol: String, val amount: String) : Informational(
data class NetworksNoAccount(
private val network: String,
private val symbol: String,
private val amount: String,
) : Informational(
title = resourceReference(R.string.warning_no_account_title),
subtitle = resourceReference(
id = R.string.no_account_generic,
@ -175,4 +179,9 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
formatArgs = wrappedList(coinSymbol),
),
)
data class NetworkShutdown(private val title: TextReference, private val subtitle: TextReference) : Warning(
title = title,
subtitle = subtitle,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
@ -8,6 +9,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.*
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.removeBy
import kotlinx.collections.immutable.ImmutableList
@ -78,6 +80,10 @@ internal class TokenDetailsNotificationConverter(
onSwapClick = clickIntents::onSwapPromoClick,
onCloseClick = clickIntents::onSwapPromoDismiss,
)
is CryptoCurrencyWarning.BeaconChainShutdown -> NetworkShutdown(
title = resourceReference(R.string.warning_beacon_chain_retirement_title),
subtitle = resourceReference(R.string.warning_beacon_chain_retirement_content),
)
}
}

View file

@ -1,8 +1,12 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UpdateWalletUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
@ -32,9 +36,13 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val walletEventSender: WalletEventSender,
private val walletScreenContentLoader: WalletScreenContentLoader,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val updateWalletUseCase: UpdateWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider,
) : BaseWalletClickIntents(), WalletCardClickIntents {
@ -72,7 +80,18 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) {
viewModelScope.launch(dispatchers.main) {
walletScreenContentLoader.cancel(userWalletId)
val deletedUserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return@launch
deleteSavedAccessCodesUseCase(cardId = deletedUserWallet.cardId)
.onLeft { Timber.e(it.toString()) }
deleteWalletUseCase(userWalletId)
.onRight {
getSelectedWalletSyncUseCase().getOrNull()?.let {
reduxStateHolder.onUserWalletSelected(it)
}
}
.onLeft { Timber.e(it.toString()) }
}
}