Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-29 13:38:50 +03:00
parent 2a689bbbe4
commit f8da5c0d58
13 changed files with 455 additions and 66 deletions

View file

@ -1,10 +0,0 @@
package com.tangem.features.send.impl.presentation.domain
sealed class SendNotification {
sealed class Info(val message: String) : SendNotification()
sealed class Critical(val message: String) : SendNotification()
sealed class Error(val message: String) : SendNotification()
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.send.impl.presentation.state
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
internal sealed class SendNotification(val config: NotificationConfig) {
sealed class Error(
title: TextReference,
subtitle: TextReference,
buttonState: NotificationConfig.ButtonsState? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_24,
buttonsState = buttonState,
),
) {
object TotalExceedsBalance : Error(
title = resourceReference(R.string.send_notification_exceed_balance_title),
subtitle = resourceReference(R.string.send_notification_exceed_balance_text),
)
object InvalidAmount : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(R.string.send_notification_invalid_amount_text),
)
data class MinimumAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(R.string.send_notification_invalid_minimum_amount_text, wrappedList(amount)),
)
data class ReserveAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_reserve_amount_title, wrappedList(amount)),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
)
data class TransactionLimitError(
val cryptoCurrency: String,
val utxoLimit: String,
val amountLimit: String,
) : Error(
title = resourceReference(R.string.send_notifiaction_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notifiaction_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
)
}
sealed class Warning(
title: TextReference,
subtitle: TextReference,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
),
) {
data class HighFeeError(val amount: String) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
)
data class ExistentialDeposit(val deposit: String) : Warning(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
)
}
}

View file

@ -0,0 +1,188 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
internal class SendNotificationFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val walletManagersFacade: WalletManagersFacade,
) {
fun create(): Flow<ImmutableList<SendNotification>> = currentStateProvider().currentState
.filter { it == SendUiStateType.Send }
.map {
val state = currentStateProvider()
val feeState = state.feeState ?: return@map persistentListOf()
val recipientState = state.recipientState ?: return@map persistentListOf()
val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO
buildList {
// errors
addExceedBalanceNotification(feeAmount, feeState.receivedAmountValue)
addInvalidAmountNotification(feeState.isSubtract, feeState.receivedAmountValue)
addMinimumAmountErrorNotification(feeAmount, feeState.receivedAmountValue)
addReserveAmountErrorNotification(recipientState.addressTextField.value)
addTransactionLimitErrorNotification(feeAmount, feeState.receivedAmountValue)
// warnings
addExistentialWarningNotification(feeAmount, feeState.receivedAmountValue)
addHighFeeWarningNotification()
}.toImmutableList()
}
private fun MutableList<SendNotification>.addExceedBalanceNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val cryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val coinCryptoAmount = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val showNotification = if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) {
receivedAmount > cryptoAmount || feeAmount > coinCryptoAmount
} else {
receivedAmount + feeAmount > cryptoAmount
}
if (showNotification) {
add(SendNotification.Error.TotalExceedsBalance)
}
}
private fun MutableList<SendNotification>.addInvalidAmountNotification(
isSubtractAmount: Boolean,
receivedAmount: BigDecimal,
) {
if (isSubtractAmount && receivedAmount <= BigDecimal.ZERO) {
add(SendNotification.Error.InvalidAmount)
}
}
private fun MutableList<SendNotification>.addMinimumAmountErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val totalAmount = feeAmount + receivedAmount
val balance = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
// TODO Move Blockchain check elsewhere
when (coinCryptoCurrencyStatus.currency.network.id.value) {
Blockchain.Cardano.id -> {
if (receivedAmount > BigDecimal.ONE || balance - totalAmount < BigDecimal.ONE) {
add(SendNotification.Error.MinimumAmountError(CARDANO_MINIMUM))
}
}
Blockchain.Dogecoin.id -> {
val minimum = BigDecimal(DOGECOIN_MINIMUM)
if (receivedAmount > minimum || balance - totalAmount < minimum) {
add(SendNotification.Error.MinimumAmountError(DOGECOIN_MINIMUM))
}
}
else -> Unit
}
}
private suspend fun MutableList<SendNotification>.addReserveAmountErrorNotification(recipientAddress: String) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val isAccountFunded = walletManagersFacade.checkIfAccountFunded(
userWalletId,
cryptoCurrency.network,
recipientAddress,
)
val minimumAmount = walletManagersFacade.getReserveAmount(userWalletId, cryptoCurrency.network)
if (!isAccountFunded && minimumAmount != null && minimumAmount > BigDecimal.ZERO) {
add(
SendNotification.Error.ReserveAmountError(
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = minimumAmount,
cryptoCurrency = cryptoCurrency,
),
),
)
}
}
private suspend fun MutableList<SendNotification>.addTransactionLimitErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val utxoLimit = walletManagersFacade.checkUtxoAmountLimit(
userWalletId = userWalletId,
network = cryptoCurrency.network,
amount = receivedAmount,
fee = feeAmount,
)
if (utxoLimit != null) {
add(
SendNotification.Error.TransactionLimitError(
cryptoCurrency = cryptoCurrency.name,
utxoLimit = utxoLimit.maxLimit.toPlainString(),
amountLimit = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = utxoLimit.maxAmount,
cryptoCurrency = cryptoCurrency,
),
),
)
}
}
private suspend fun MutableList<SendNotification>.addExistentialWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
feeAmount + receivedAmount
}
val currencyDeposit = walletManagersFacade.getExistentialDeposit(
userWalletId,
cryptoCurrency.network,
)
if (currencyDeposit != null && currencyDeposit > spendingAmount) {
add(
SendNotification.Warning.ExistentialDeposit(
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = currencyDeposit,
cryptoCurrency = cryptoCurrency,
),
),
)
}
}
private fun MutableList<SendNotification>.addHighFeeWarningNotification() {
// TODO Move Blockchain check elsewhere
if (cryptoCurrencyStatusProvider().currency.network.id.value == Blockchain.Tezos.id) {
add(SendNotification.Warning.HighFeeError(TEZOS_FEE_THRESHOLD))
}
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"
private const val TEZOS_FEE_THRESHOLD = "0.01"
}
}

View file

@ -24,6 +24,7 @@ import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientS
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
@ -238,7 +239,7 @@ internal class SendStateFactory(
)
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = feeSelectorState,
fee = fee,
@ -260,7 +261,7 @@ internal class SendStateFactory(
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
fee = fee,
@ -288,7 +289,7 @@ internal class SendStateFactory(
)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
@ -310,7 +311,7 @@ internal class SendStateFactory(
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
val receivedAmount = calculateReceiveAmount(state, fee, value)
val updatedState = feeState.copy(
isSubtract = value,
fee = fee,
@ -375,5 +376,16 @@ internal class SendStateFactory(
),
)
}
fun getSendNotificationState(notifications: ImmutableList<SendNotification>): SendUiState {
val state = currentStateProvider()
val hasErrorNotifications = notifications.any { it is SendNotification.Error }
return state.copy(
sendState = state.sendState.copy(
isPrimaryButtonEnabled = !hasErrorNotifications,
notifications = notifications,
),
)
}
//endregion
}

View file

@ -86,6 +86,7 @@ internal sealed class SendStates {
val isSuccess: Boolean = false,
val transactionDate: Long = 0L,
val txUrl: String = "",
val notifications: ImmutableList<SendNotification> = persistentListOf(),
) : SendStates()
}

View file

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

View file

@ -187,6 +187,7 @@ private fun isButtonEnabled(currentState: State<SendUiStateType>, uiState: SendU
SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Send -> uiState.sendState.isPrimaryButtonEnabled
else -> true
}
}

View file

@ -1,10 +1,13 @@
package com.tangem.features.send.impl.presentation.ui.send
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -18,14 +21,17 @@ import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImage
import com.tangem.core.ui.components.inputrow.InputRowRecipientDefault
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import kotlinx.collections.immutable.ImmutableList
@Suppress("LongMethod")
@Composable
@ -75,6 +81,7 @@ internal fun SendContent(uiState: SendUiState) {
)
}
}
notifications(sendState.notifications)
}
}
@ -178,4 +185,24 @@ private fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick:
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess) { onClick() },
)
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.notifications(configs: ImmutableList<SendNotification>, modifier: Modifier = Modifier) {
items(
items = configs,
key = { it::class.java },
contentType = { it::class.java },
itemContent = {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
containerColor = TangemTheme.colors.button.disabled,
iconTint = when (it) {
is SendNotification.Error -> TangemTheme.colors.icon.warning
is SendNotification.Warning -> null
},
)
},
)
}

View file

@ -96,6 +96,14 @@ internal class SendViewModel @Inject constructor(
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
)
private val sendNotificationFactory = SendNotificationFactory(
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
walletManagersFacade = walletManagersFacade,
)
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
@ -107,6 +115,7 @@ internal class SendViewModel @Inject constructor(
private var recipientsJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
private var sendNotificationsJobHolder = JobHolder()
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
@ -146,6 +155,7 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatus = it
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
updateNotifications()
}
}
.flowOn(dispatchers.main)
@ -300,6 +310,16 @@ internal class SendViewModel @Inject constructor(
}.saveIn(feeJobHolder)
}
private fun updateNotifications() {
sendNotificationFactory.create()
.conflate()
.distinctUntilChanged()
.onEach { uiState = stateFactory.getSendNotificationState(notifications = it) }
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(sendNotificationsJobHolder)
}
// region screen state navigation
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick()
@ -376,7 +396,7 @@ internal class SendViewModel @Inject constructor(
}
return false
}
// endregion
// endregion
// region fee
override fun onFeeSelectorClick(feeType: FeeType) {
@ -410,18 +430,13 @@ internal class SendViewModel @Inject constructor(
override fun onExploreClick(txUrl: String) = innerRouter.openUrl(txUrl)
private suspend fun verifyAndSendTransaction() {
val amount = uiState.amountState?.amountTextField?.value ?: return
val recipient = uiState.recipientState?.addressTextField?.value ?: return
val feeState = uiState.feeState ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val memo = uiState.recipientState?.memoTextField?.value
val fee = feeSelectorState.getFee()
val amountToSend = if (feeState.isSubtract) {
feeState.receivedAmountValue.convertToAmount(cryptoCurrency)
} else {
amount.toBigDecimal().convertToAmount(cryptoCurrency)
}
val amountToSend = feeState.receivedAmountValue.convertToAmount(cryptoCurrency)
// todo add error handling [[REDACTED_JIRA]]
// val transactionErrors = walletManagersFacade.validateTransaction(