Updated on 2026-08-14

This commit is contained in:
Tangem 2024-01-23 13:53:00 +03:00
commit 3b9b6ef859
15 changed files with 465 additions and 339 deletions

View file

@ -7,7 +7,6 @@ import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,11 +19,8 @@ internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetFeeUseCase(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers)
fun provideGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade)
}
@Provides

View file

@ -1,21 +1,15 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.math.BigDecimal
/**
@ -23,16 +17,15 @@ import java.math.BigDecimal
*/
class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val dispatcher: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
amount: BigDecimal,
destination: String,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Either<GetFeeError, TransactionFee>> {
return flow {
try {
) = either {
catch(
block = {
val result = requireNotNull(
walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
@ -43,14 +36,15 @@ class GetFeeUseCase(
) { "Fee is null" }
val maybeFee = when (result) {
is Result.Success -> result.data.right()
is Result.Failure -> GetFeeError.DataError(result.error).left()
is Result.Success -> result.data
is Result.Failure -> raise(GetFeeError.DataError(result.error))
}
emit(maybeFee)
} catch (e: Exception) {
emit(GetFeeError.DataError(e.cause).left())
}
}.flowOn(dispatcher.io)
maybeFee
},
catch = {
raise(GetFeeError.DataError(it))
},
)
}
private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount(

View file

@ -3,11 +3,9 @@ package com.tangem.features.send.impl.presentation.state
import androidx.paging.PagingData
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
@ -17,22 +15,19 @@ import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.amount.SendAmountStateConverter
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList")
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
@ -41,24 +36,11 @@ internal class SendStateFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountFieldConverter by lazy { SendAmountFieldConverter(clickIntents) }
private val amountFieldChangeConverter by lazy { SendAmountFieldChangeConverter(currentStateProvider) }
private val customFeeFieldConverter by lazy {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
)
}
private val feeNotificationFactory = FeeNotificationFactory(
coinCryptoCurrencyStatusProvider = coinCryptoCurrencyStatusProvider,
userWalletProvider = userWalletProvider,
clickIntents = clickIntents,
)
private val amountStateConverter by lazy {
SendAmountStateConverter(
@ -219,144 +201,6 @@ internal class SendStateFactory(
}
//endregion
//region fee
fun onFeeOnLoadingState(): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = FeeSelectorState.Loading
return state.copy(
feeState = feeState.copy(
feeSelectorState = feeSelectorState,
notifications = persistentListOf(),
isPrimaryButtonEnabled = feeSelectorState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
isPrimaryButtonEnabled = feeSelectorState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeSelectedState(feeType: FeeType): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
feeSelectorState = updatedFeeSelectorState,
isPrimaryButtonEnabled = updatedFeeSelectorState.isPrimaryButtonEnabled(),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
fun onCustomFeeValueChange(index: Int, value: String): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(
customValues = feeSelectorState.customValues.toMutableList().apply {
set(index, feeSelectorState.customValues[index].copy(value = value))
}.toImmutableList(),
)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isPrimaryButtonEnabled = updatedFeeSelectorState.isPrimaryButtonEnabled(),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, value)
val updatedState = feeState.copy(
isSubtract = value,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = if (value) {
getFormattedValue(receivedAmount)
} else {
feeState.receivedAmount
},
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
private fun FeeSelectorState.isPrimaryButtonEnabled(): Boolean {
return when (this) {
is FeeSelectorState.Loading -> false
is FeeSelectorState.Content -> {
val customValue = customValues.firstOrNull()?.value?.toBigDecimalOrNull()
val balance = cryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = getFee().amount.value ?: BigDecimal.ZERO
val isNotEmptyCustom = !customValue.isNullOrZero() && selectedFee == FeeType.CUSTOM
val isNotCustom = selectedFee != FeeType.CUSTOM
fee < balance && (isNotEmptyCustom || isNotCustom)
}
}
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
//endregion
//region send
fun getSendingStateUpdate(isSending: Boolean): SendUiState {
val state = currentStateProvider()
@ -376,6 +220,7 @@ internal class SendStateFactory(
transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(),
isSuccess = true,
txUrl = txUrl,
notifications = persistentListOf(),
),
)
}

View file

@ -54,6 +54,7 @@ internal sealed class SendStates {
val isFiatValue: Boolean,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val amountTextField: SendTextField.Amount,
val amountValue: BigDecimal,
) : SendStates()
/** Recipient state */
@ -74,6 +75,7 @@ internal sealed class SendStates {
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val feeSelectorState: FeeSelectorState = FeeSelectorState.Loading,
val isSubtract: Boolean = false,
val isUserSubtracted: Boolean = false,
val fee: Fee? = null,
val receivedAmountValue: BigDecimal = BigDecimal.ZERO,
val receivedAmount: String = "",

View file

@ -12,6 +12,7 @@ import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldCo
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal class SendAmountStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
@ -37,6 +38,7 @@ internal class SendAmountStateConverter(
amountTextField = sendAmountFieldConverter.convert(Unit),
isFiatValue = false,
isPrimaryButtonEnabled = false,
amountValue = BigDecimal.ZERO,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),

View file

@ -8,14 +8,10 @@ import java.math.BigDecimal
/**
* Calculate receiving amount when fee is subtracted from sending amount
*/
internal fun calculateReceiveAmount(uiState: SendUiState, feeAmount: Fee, isSubtract: Boolean): BigDecimal {
val amount = uiState.amountState?.amountTextField?.value ?: return BigDecimal.ZERO
internal fun calculateReceiveAmount(state: SendUiState, feeAmount: Fee): BigDecimal {
val amountValue = state.amountState?.amountValue ?: BigDecimal.ZERO
val fee = feeAmount.amount.value ?: return BigDecimal.ZERO
return if (isSubtract) {
amount.toBigDecimal().minus(fee)
} else {
amount.toBigDecimal()
}
return amountValue.minus(fee)
}
/**

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -9,7 +10,6 @@ import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@ -19,17 +19,28 @@ internal class FeeNotificationFactory(
private val clickIntents: SendClickIntents,
) {
operator fun invoke(feeState: SendStates.FeeState): ImmutableList<SendFeeNotification> {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return persistentListOf()
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
return buildList {
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification()
addExceedsBalanceNotification(feeSelectorState)
operator fun invoke(feeState: SendStates.FeeState, amountValue: BigDecimal?): ImmutableList<SendFeeNotification> =
buildList {
when (val feeSelectorState = feeState.feeSelectorState) {
FeeSelectorState.Loading -> Unit
FeeSelectorState.Error -> {
addFeeUnreachableNotification(feeSelectorState)
}
is FeeSelectorState.Content -> {
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification(feeState, amountValue)
addExceedsBalanceNotification(feeState.fee)
}
}
}.toImmutableList()
private fun MutableList<SendFeeNotification>.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState is FeeSelectorState.Error) {
add(SendFeeNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload))
}
}
private fun MutableList<SendFeeNotification>.addTooLowNotification(
@ -59,25 +70,24 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification() {
// TODO add fee coverage condition [REDACTED_JIRA]
add(SendFeeNotification.Warning.NetworkCoverage)
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification(
feeState: SendStates.FeeState,
amountValue: BigDecimal?,
) {
val cryptoAmount = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeValue = feeState.fee?.amount?.value ?: BigDecimal.ZERO
val value = amountValue ?: BigDecimal.ZERO
if (cryptoAmount <= value + feeValue && feeState.isSubtract && !feeState.isUserSubtracted) {
add(SendFeeNotification.Warning.NetworkCoverage)
}
}
private fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(
feeSelectorState: FeeSelectorState.Content,
) {
private fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(fee: Fee?) {
val coinCryptoCurrency = coinCryptoCurrencyStatusProvider()
val cryptoAmount = coinCryptoCurrency.value.amount ?: BigDecimal.ZERO
val choosableFee = feeSelectorState.fees as? TransactionFee.Choosable
val fee = when (feeSelectorState.selectedFee) {
FeeType.SLOW -> choosableFee?.minimum?.amount?.value
FeeType.MARKET -> feeSelectorState.fees.normal.amount.value
FeeType.FAST -> choosableFee?.priority?.amount?.value
FeeType.CUSTOM -> feeSelectorState.customValues.firstOrNull()?.value?.toBigDecimalOrNull()
} ?: return
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
if (fee > cryptoAmount) {
if (feeValue > cryptoAmount) {
add(
SendFeeNotification.Error.ExceedsBalance(
coinCryptoCurrency.currency.networkIconResId,

View file

@ -16,6 +16,8 @@ internal sealed class FeeSelectorState {
val selectedFee: FeeType = FeeType.MARKET,
val customValues: ImmutableList<SendTextField.CustomFee> = persistentListOf(),
) : FeeSelectorState()
object Error : FeeSelectorState()
}
enum class FeeType {

View file

@ -0,0 +1,217 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
/**
* Factory to produce fee state for [SendUiState]
*/
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
userWalletProvider: Provider<UserWallet>,
) {
private val customFeeFieldConverter by lazy {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
)
}
private val feeNotificationFactory = FeeNotificationFactory(
coinCryptoCurrencyStatusProvider = coinCryptoCurrencyStatusProvider,
userWalletProvider = userWalletProvider,
clickIntents = clickIntents,
)
fun onFeeOnLoadingState(): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
return state.copy(
feeState = feeState.copy(
feeSelectorState = FeeSelectorState.Loading,
notifications = persistentListOf(),
isPrimaryButtonEnabled = feeState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val updatedState = feeState.copy(
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(
feeState = updatedState,
amountValue = state.amountState?.amountValue,
),
isPrimaryButtonEnabled = updatedState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeOnErrorState(): SendUiState {
val state = currentStateProvider()
val updatedState = state.feeState?.copy(
feeSelectorState = FeeSelectorState.Error,
)
return state.copy(
feeState = updatedState?.copy(
notifications = feeNotificationFactory(
feeState = updatedState,
amountValue = state.amountState?.amountValue,
),
),
)
}
fun onFeeSelectedState(feeType: FeeType): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val updatedState = feeState.copy(
fee = fee,
feeSelectorState = updatedFeeSelectorState,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(
feeState = updatedState,
amountValue = state.amountState?.amountValue,
),
isPrimaryButtonEnabled = updatedState.isPrimaryButtonEnabled(),
),
)
}
fun onCustomFeeValueChange(index: Int, value: String): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(
customValues = feeSelectorState.customValues.toMutableList().apply {
set(index, feeSelectorState.customValues[index].copy(value = value))
}.toImmutableList(),
)
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val updatedState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(
feeState = updatedState,
amountValue = state.amountState?.amountValue,
),
isPrimaryButtonEnabled = updatedState.isPrimaryButtonEnabled(),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val updatedState = feeState.copy(
isSubtract = value,
isUserSubtracted = true,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
fee = fee,
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(
feeState = updatedState,
amountValue = state.amountState?.amountValue,
),
isPrimaryButtonEnabled = updatedState.isPrimaryButtonEnabled(),
),
)
}
private fun SendStates.FeeState.isPrimaryButtonEnabled(): Boolean {
return when (feeSelectorState) {
is FeeSelectorState.Content -> {
val customValue = feeSelectorState.customValues.firstOrNull()?.value?.toBigDecimalOrNull()
val balance = cryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeSelectorState.getFee().amount.value ?: BigDecimal.ZERO
val isNotCustom = feeSelectorState.selectedFee != FeeType.CUSTOM
val isNotEmptyCustom = !customValue.isNullOrZero() && !isNotCustom
val isSubtractRequired = if (fee + receivedAmountValue >= balance) isSubtract else true
val isBalanceEnough = fee + receivedAmountValue <= balance
isBalanceEnough && isSubtractRequired && (isNotEmptyCustom || isNotCustom)
}
else -> false
}
}
private fun checkAutoSubtract(state: SendUiState, fee: Fee, balance: BigDecimal): Boolean {
val feeState = state.feeState ?: return false
val amountValue = state.amountState?.amountValue ?: BigDecimal.ZERO
val feeAmount = fee.amount.value ?: BigDecimal.ZERO
return if (feeState.isUserSubtracted) {
feeState.isSubtract
} else {
amountValue + feeAmount >= balance
}
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
}

View file

@ -1,13 +1,11 @@
package com.tangem.features.send.impl.presentation.state.fee
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.send.impl.R
@Immutable
sealed class SendFeeNotification(val config: NotificationConfig) {
sealed class Informational(
@ -29,11 +27,13 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
sealed class Warning(
val title: TextReference,
val subtitle: TextReference,
val buttonsState: NotificationConfig.ButtonsState? = null,
) : SendFeeNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
),
) {
data class TooHigh(
@ -47,6 +47,15 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(id = R.string.send_network_fee_warning_content),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,
),
)
}
sealed class Error(

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -41,8 +42,9 @@ internal class SendAmountFieldChangeConverter(
trimmedValue
}
val isExceedBalance = cryptoValue.checkExceedBalance(amountState)
val isMaxAmount = cryptoValue.checkMaxAmount(amountState)
val checkValue = if (amountState.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(amountState)
val isMaxAmount = checkValue.checkMaxAmount(amountState)
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance,
@ -51,6 +53,7 @@ internal class SendAmountFieldChangeConverter(
fiatValue = fiatValue,
isError = isExceedBalance,
),
amountValue = cryptoValue.toBigDecimalOrDefault(),
),
feeState = feeState.copy(
isSubtract = isMaxAmount,

View file

@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -16,7 +15,6 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
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
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
@ -36,7 +34,6 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
.padding(
horizontal = TangemTheme.dimens.spacing16,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
item(
key = FEE_SELECTOR_KEY,
@ -46,13 +43,12 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
clickIntents = clickIntents,
)
}
notifications(notifications)
customFee(
feeSendState = feeSendState,
cryptoCurrencySymbol = state.cryptoCurrencyStatus.currency.symbol,
)
notifications(notifications)
subtractButton(
feeSendState = feeSendState,
receivedAmount = state.receivedAmount,
isSubtract = state.isSubtract,
clickIntents = clickIntents,
@ -69,8 +65,15 @@ internal fun LazyListScope.notifications(configs: ImmutableList<SendFeeNotificat
itemContent = {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
containerColor = TangemTheme.colors.button.disabled,
modifier = modifier
.padding(top = TangemTheme.dimens.spacing12)
.animateItemPlacement(),
containerColor = when (it) {
is SendFeeNotification.Error.ExceedsBalance,
is SendFeeNotification.Warning.NetworkFeeUnreachable,
-> TangemTheme.colors.background.primary
else -> TangemTheme.colors.button.disabled
},
iconTint = when (it) {
is SendFeeNotification.Informational -> TangemTheme.colors.icon.accent
else -> null
@ -102,6 +105,7 @@ internal fun LazyListScope.customFee(
customValues = customValues,
selectedFee = fee.selectedFee,
symbol = cryptoCurrencySymbol,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
)
}
}
@ -110,32 +114,19 @@ internal fun LazyListScope.customFee(
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.subtractButton(
feeSendState: FeeSelectorState,
receivedAmount: String,
isSubtract: Boolean,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
(feeSendState as? FeeSelectorState.Content)?.let { state ->
item {
val selectedFeeValue = state.selectedFee
val topPadding = if (selectedFeeValue != FeeType.CUSTOM) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.animateItemPlacement()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing12,
),
)
}
item {
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing12)
.animateItemPlacement(),
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.impl.presentation.ui.fee
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -13,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@ -26,12 +28,19 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
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.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
private val DEFAULT_FEE_OPTIONS = listOf(
R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24,
R.string.common_fee_selector_option_market to R.drawable.ic_bird_24,
R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24,
)
@Suppress("LongMethod")
@Composable
internal fun SendSpeedSelector(
@ -50,10 +59,11 @@ internal fun SendSpeedSelector(
.background(TangemTheme.colors.background.action),
) {
when (state) {
FeeSelectorState.Error -> {
SendSpeedSelectorItemError()
}
FeeSelectorState.Loading -> {
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
}
is FeeSelectorState.Content -> {
when (state.fees) {
@ -84,7 +94,10 @@ internal fun SendSpeedSelector(
onSelect = { clickIntents.onFeeSelectorClick(FeeType.FAST) },
showDivider = state.fees.normal is Fee.Ethereum,
)
if (state.fees.normal is Fee.Ethereum) {
AnimatedVisibility(
visible = state.fees.normal is Fee.Ethereum,
label = "Custom fee appearance animation",
) {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
@ -114,34 +127,52 @@ internal fun SendSpeedSelector(
@Composable
private fun SendSpeedSelectorItemLoading() {
Row(modifier = Modifier.fillMaxWidth()) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
start = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size50,
height = TangemTheme.dimens.size12,
),
)
SpacerWMax()
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
end = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size90,
height = TangemTheme.dimens.size12,
),
)
repeat(DEFAULT_FEE_OPTIONS.size) {
val (text, iconRes) = DEFAULT_FEE_OPTIONS[it]
Row(modifier = Modifier.fillMaxWidth()) {
SelectorTitleContent(
titleRes = text,
iconRes = iconRes,
)
SpacerWMax()
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
end = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size90,
height = TangemTheme.dimens.size12,
),
)
}
}
}
@Composable
private fun SendSpeedSelectorItemError() {
repeat(DEFAULT_FEE_OPTIONS.size) {
val (text, iconRes) = DEFAULT_FEE_OPTIONS[it]
Row(modifier = Modifier.fillMaxWidth()) {
SelectorTitleContent(
titleRes = text,
iconRes = iconRes,
)
SpacerWMax()
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing12,
),
)
}
}
}
@ -177,27 +208,11 @@ private fun SendSpeedSelectorItem(
.clickable { onSelect() },
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
SelectorTitleContent(
titleRes = titleRes,
iconRes = iconRes,
iconTint = iconTint,
textStyle = textStyle,
)
if (amount != null && symbol != null) {
SelectorValueContent(
@ -220,6 +235,37 @@ private fun SendSpeedSelectorItem(
}
}
@Composable
private fun SelectorTitleContent(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
iconTint: Color = TangemTheme.colors.icon.informative,
textStyle: TextStyle = TangemTheme.typography.body2,
) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
}
@Composable
private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) {
Text(

View file

@ -36,6 +36,8 @@ interface SendClickIntents {
// endregion
// region Fee
fun feeReload()
fun onFeeSelectorClick(feeType: FeeType)
fun onCustomFeeValueChange(index: Int, value: String)

View file

@ -35,6 +35,7 @@ import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.getFee
import com.tangem.utils.Provider
@ -93,11 +94,21 @@ internal class SendViewModel @Inject constructor(
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
validateWalletMemoUseCase = validateWalletMemoUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
)
private val feeStateFactory by lazy {
FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
userWalletProvider = Provider { userWallet },
)
}
private val eventStateFactory = SendEventStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
@ -129,7 +140,7 @@ internal class SendViewModel @Inject constructor(
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
getFee()
onFeeStateActive()
}
fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) {
@ -289,37 +300,11 @@ internal class SendViewModel @Inject constructor(
}
}
private fun getFee() {
viewModelScope.launch(dispatchers.main) {
uiState.currentState
.filter { it == SendUiStateType.Fee }
.onEach {
val amountState = uiState.amountState ?: return@onEach
val recipientState = uiState.recipientState ?: return@onEach
val amount = amountState.amountTextField.value.toBigDecimal()
uiState = stateFactory.onFeeOnLoadingState()
getFeeUseCase.invoke(
amount = amount,
destination = recipientState.addressTextField.value,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
.conflate()
.distinctUntilChanged()
.onEach { maybeFee ->
maybeFee.fold(
ifRight = {
uiState = stateFactory.onFeeOnLoadedState(it)
},
ifLeft = {
// todo add error handling [[REDACTED_JIRA]]
},
)
}
.launchIn(viewModelScope)
}.launchIn(viewModelScope)
}.saveIn(feeJobHolder)
private fun onFeeStateActive() {
uiState.currentState
.filter { it == SendUiStateType.Fee }
.onEach { loadFee() }
.launchIn(viewModelScope)
}
private fun updateNotifications() {
@ -428,16 +413,41 @@ internal class SendViewModel @Inject constructor(
// endregion
// region fee
override fun feeReload() = loadFee()
override fun onFeeSelectorClick(feeType: FeeType) {
uiState = stateFactory.onFeeSelectedState(feeType)
uiState = feeStateFactory.onFeeSelectedState(feeType)
}
override fun onCustomFeeValueChange(index: Int, value: String) {
uiState = stateFactory.onCustomFeeValueChange(index, value)
uiState = feeStateFactory.onCustomFeeValueChange(index, value)
}
override fun onSubtractSelect(value: Boolean) {
uiState = stateFactory.onSubtractSelect(value)
uiState = feeStateFactory.onSubtractSelect(value)
}
private fun loadFee() {
viewModelScope.launch(dispatchers.main) {
val amountState = uiState.amountState ?: return@launch
val recipientState = uiState.recipientState ?: return@launch
val amount = amountState.amountTextField.value.toBigDecimal()
uiState = feeStateFactory.onFeeOnLoadingState()
getFeeUseCase.invoke(
amount = amount,
destination = recipientState.addressTextField.value,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
).fold(
ifRight = {
uiState = feeStateFactory.onFeeOnLoadedState(it)
},
ifLeft = {
uiState = feeStateFactory.onFeeOnErrorState()
},
)
}.saveIn(feeJobHolder)
}
// endregion
@ -461,7 +471,7 @@ internal class SendViewModel @Inject constructor(
override fun onAmountReduceClick(reducedAmount: String) {
uiState = stateFactory.getOnAmountValueChange(reducedAmount)
uiState = sendNotificationFactory.dismissHighFeeWarningState()
getFee()
loadFee()
}
override fun onAmountReduceIgnoreClick() {
@ -474,12 +484,13 @@ internal class SendViewModel @Inject constructor(
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val memo = uiState.recipientState?.memoTextField?.value
val fee = feeSelectorState.getFee()
val amountValue = uiState.amountState?.amountValue ?: return
val amountToSend = feeState.receivedAmountValue.convertToAmount(cryptoCurrency)
val amountToSend = if (feeState.isSubtract) feeState.receivedAmountValue else amountValue
viewModelScope.launch(dispatchers.main) {
createTransactionUseCase(
amount = amountToSend,
amount = amountToSend.convertToAmount(cryptoCurrency),
fee = fee,
memo = memo,
destination = recipient,