Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-10 18:41:26 +01:00
commit 3fba2ae059
872 changed files with 17188 additions and 8108 deletions

View file

@ -6,10 +6,10 @@ import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.state.StateRouter
@ -26,7 +26,7 @@ import javax.inject.Inject
internal class SendFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var router: SendRouter

View file

@ -1,10 +1,13 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.Blockchain
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.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
import java.math.BigDecimal
internal sealed class SendNotification(val config: NotificationConfig) {
@ -162,4 +165,70 @@ internal sealed class SendNotification(val config: NotificationConfig) {
),
)
}
sealed interface Cardano {
data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Warning(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
)
data object InsufficientBalanceToTransferCoin : Error(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
)
data class InsufficientBalanceToTransferToken(val tokenName: String) : Error(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
)
}
sealed interface Koinos {
data class InsufficientRecoverableMana(
val mana: BigDecimal,
val maxMana: BigDecimal,
) : Error(
title = resourceReference(R.string.koinos_insufficient_mana_to_send_koin_title),
subtitle = resourceReference(
R.string.koinos_insufficient_mana_to_send_koin_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmountShorted(mana, "", Blockchain.Koinos.decimals()),
BigDecimalFormatter.formatCryptoAmountShorted(maxMana, "", Blockchain.Koinos.decimals()),
),
),
)
data object InsufficientBalance : Error(
title = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_title),
subtitle = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_description),
)
data class ManaExceedsBalance(
val availableKoinForTransfer: BigDecimal,
val onReduceClick: () -> Unit,
) : Error(
title = resourceReference(R.string.koinos_mana_exceeds_koin_balance_title),
subtitle = resourceReference(
R.string.koinos_mana_exceeds_koin_balance_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmount(
availableKoinForTransfer,
Blockchain.Koinos.currency,
Blockchain.Koinos.decimals(),
),
),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(availableKoinForTransfer)),
onClick = onReduceClick,
),
)
}
}

View file

@ -1,18 +1,11 @@
package com.tangem.features.send.impl.presentation.state
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.wallets.models.UserWallet
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.common.SendSyncEditConverter
import com.tangem.features.send.impl.presentation.state.confirm.SendConfirmStateConverter
@ -20,18 +13,14 @@ import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fee.checkFeeCoverage
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientHistoryListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientWalletListConverter
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.toPersistentList
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList")
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
@ -41,7 +30,6 @@ internal class SendStateFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val isTapHelpPreviewEnabledProvider: Provider<Boolean>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -72,6 +60,7 @@ internal class SendStateFactory(
SendFeeStateConverter(
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val confirmStateConverter by lazy(LazyThreadSafetyMode.NONE) {
@ -79,14 +68,6 @@ internal class SendStateFactory(
isTapHelpPreviewEnabledProvider = isTapHelpPreviewEnabledProvider,
)
}
private val recipientWalletListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientWalletListConverter()
}
private val recipientHistoryListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientHistoryListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val sendSyncEditConverter by lazy(LazyThreadSafetyMode.NONE) {
SendSyncEditConverter(currentStateProvider = currentStateProvider)
}
@ -132,156 +113,6 @@ internal class SendStateFactory(
}
//endregion
//region recipient
fun onLoadedWalletsList(wallets: List<AvailableWallet?>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
wallets = recipientWalletListStateConverter.convert(wallets),
),
)
}
fun onLoadedHistoryList(txHistory: List<TxHistoryItem>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
recent = recipientHistoryListStateConverter.convert(txHistory),
),
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
addressTextField = recipientState.addressTextField.copy(value = value),
memoTextField = recipientState.memoTextField?.copy(isEnabled = !isXAddress),
),
)
}
fun getOnRecipientAddressValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = recipientState.memoTextField?.value.orEmpty(),
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
val isAddressInWallet = cryptoCurrencyStatus.value.networkAddress?.availableAddresses
?.any { it.value == value } ?: true
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && !isAddressInWallet,
isValidating = false,
addressTextField = recipientState.addressTextField.copy(
error = when {
!isValidAddress -> resourceReference(R.string.send_recipient_address_error)
isAddressInWallet -> resourceReference(R.string.send_error_address_same_as_wallet)
else -> null
},
isError = value.isNotEmpty() && !isValidAddress || isAddressInWallet,
),
),
)
}
fun getOnRecipientAddressValidationStarted(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(isValidating = true),
)
}
fun getOnRecipientMemoValueChange(value: String): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(value = value),
),
)
}
fun getOnRecipientMemoValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = value,
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
val isAddressInWallet = cryptoCurrencyStatus.value.networkAddress?.availableAddresses
?.any { it.value == value } ?: true
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress && !isAddressInWallet,
isValidating = false,
memoTextField = recipientState.memoTextField?.copy(
isError = value.isNotEmpty() && !isValidMemo,
isEnabled = true,
),
),
)
}
fun getOnXAddressMemoState(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = "",
isEnabled = false,
),
),
)
}
fun getHiddenRecentListState(isAddressInWallet: Boolean, isValidAddress: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isNotValid = isAddressInWallet || !isValidAddress
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
recent = recipientState.recent.map { recent ->
recent.copy(isVisible = isNotValid && (recent.isLoading || recent.title != TextReference.EMPTY))
}.toPersistentList(),
wallets = recipientState.wallets.map { wallet ->
wallet.copy(isVisible = isNotValid && (wallet.isLoading || wallet.title != TextReference.EMPTY))
}.toPersistentList(),
),
)
}
//endregion
//region send
fun getIsAmountSubtractedState(isAmountSubtractAvailable: Boolean): SendUiState {
val state = currentStateProvider()
@ -335,7 +166,8 @@ internal class SendStateFactory(
val reducedBy = sendState.reduceAmountBy.takeIf {
notifications.none {
it is SendNotification.Error.ExistentialDeposit ||
it is SendNotification.Error.TransactionLimitError
it is SendNotification.Error.TransactionLimitError ||
it is SendNotification.Warning.HighFeeError
}
}
return state.copy(

View file

@ -71,6 +71,7 @@ internal data class SendUiState(
editAmountState = amountState,
editFeeState = feeState,
editRecipientState = recipientState,
sendState = sendState,
)
} else {
copy(
@ -125,6 +126,7 @@ internal sealed class SendStates {
val feeSelectorState: FeeSelectorState,
val fee: Fee?,
val rate: BigDecimal?,
val isFeeConvertibleToFiat: Boolean,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
val isCustomSelected: Boolean,

View file

@ -1,20 +1,24 @@
package com.tangem.features.send.impl.presentation.state.confirm
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.minimalAmount
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
@ -23,6 +27,7 @@ import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.utils.getFiatString
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
@ -46,6 +51,7 @@ internal class SendNotificationFactory(
private val clickIntents: SendClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val validateTransactionUseCase: ValidateTransactionUseCase,
) {
fun create(): Flow<ImmutableList<SendNotification>> = stateRouterProvider().currentState
@ -80,8 +86,9 @@ internal class SendNotificationFactory(
addFeeUnreachableNotification(feeState.feeSelectorState)
addExceedBalanceNotification(feeValue, sendingAmount)
addExceedsBalanceNotification(feeState.fee)
addDustWarningNotification(feeValue, sendingAmount)
addDustWarningNotificationForSpecificBlockchains(feeValue, sendingAmount)
addTransactionLimitErrorNotification(feeValue, sendingAmount)
// warnings
addExistentialWarningNotification(feeValue, amountValue)
addFeeCoverageNotification(
@ -92,6 +99,13 @@ internal class SendNotificationFactory(
addHighFeeWarningNotification(amountValue, sendState.ignoreAmountReduce)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
// blockchain specific
addValidateTransactionNotifications(
sendingAmount = sendingAmount,
fee = feeState.fee,
state = state,
)
}.toImmutableList()
}
@ -280,23 +294,35 @@ internal class SendNotificationFactory(
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
private suspend fun MutableList<SendNotification>.addDustWarningNotificationForSpecificBlockchains(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
) ?: return
val isCardano = BlockchainUtils.isCardano(cryptoCurrencyStatusProvider().currency.network.id.value)
if (checkDustLimits(feeAmount, receivedAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(dustValue.toPlainString()),
)
if (!isCardano) {
addDustWarningNotification(feeValue, sendingAmount)
}
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val change = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
balance - (feeAmount + receivedAmount)
}
is CryptoCurrency.Token -> {
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
balance - feeAmount
}
}
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
@ -398,13 +424,123 @@ internal class SendNotificationFactory(
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
private suspend fun MutableList<SendNotification>.addValidateTransactionNotifications(
sendingAmount: BigDecimal,
fee: Fee?,
state: SendUiState,
) {
val sendingCurrency = cryptoCurrencyStatusProvider().currency
val totalAmount = feeAmount + receivedAmount
val change = balance - totalAmount
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
validateTransactionUseCase(
amount = sendingAmount.convertToAmount(sendingCurrency),
fee = fee ?: return,
memo = state.recipientState?.memoTextField?.value,
destination = requireNotNull(state.recipientState?.addressTextField?.value),
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
).fold(
ifLeft = {
when (it) {
is BlockchainSdkError.Cardano -> addCardanoTransactionValidationError(
error = it,
sendingCurrency = sendingCurrency,
)
is BlockchainSdkError.Koinos -> addKoinosTransactionValidationError(error = it)
else -> return
}
},
ifRight = {
(fee as? Fee.CardanoToken)?.let {
add(
SendNotification.Cardano.MinAdaValueCharged(
tokenName = sendingCurrency.name,
minAdaValue = it.minAdaValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
},
)
}
private suspend fun MutableList<SendNotification>.addCardanoTransactionValidationError(
error: BlockchainSdkError.Cardano,
sendingCurrency: CryptoCurrency,
) {
when (error) {
BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> {
add(SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name))
}
BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> {
when (sendingCurrency) {
is CryptoCurrency.Coin -> SendNotification.Cardano.InsufficientBalanceToTransferCoin
is CryptoCurrency.Token -> {
SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name)
}
}.let(::add)
}
BlockchainSdkError.Cardano.InsufficientRemainingBalance,
BlockchainSdkError.Cardano.InsufficientSendingAdaAmount,
-> {
val dustValue = currencyChecksRepository.getDustValue(
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
) ?: return
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
}
}
private fun MutableList<SendNotification>.addKoinosTransactionValidationError(error: BlockchainSdkError.Koinos) {
when (error) {
is BlockchainSdkError.Koinos.InsufficientBalance -> {
add(SendNotification.Koinos.InsufficientBalance)
}
is BlockchainSdkError.Koinos.InsufficientMana -> {
add(
SendNotification.Koinos.InsufficientRecoverableMana(
mana = error.manaBalance ?: BigDecimal.ZERO,
maxMana = error.maxMana ?: BigDecimal.ZERO,
),
)
}
is BlockchainSdkError.Koinos.ManaFeeExceedsBalance -> {
add(
SendNotification.Koinos.ManaExceedsBalance(
availableKoinForTransfer = error.availableKoinForTransfer,
onReduceClick = {
clickIntents.onAmountReduceClick(
reduceAmountTo = error.availableKoinForTransfer,
clazz = SendNotification.Koinos.InsufficientRecoverableMana::class.java,
)
},
),
)
}
else -> {}
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
) ?: return
if (checkDustLimits(feeValue, sendingAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(cryptoCurrencyStatus.currency.decimals),
),
)
}
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
@ -21,6 +22,7 @@ internal class SendFeeStateConverter(
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
isCustomSelected = false,
isFeeConvertibleToFiat = cryptoCurrencyStatusProvider().currency.network.hasFiatFeeRate,
)
}
}

View file

@ -50,6 +50,7 @@ internal class SendAmountFieldChangeConverter(
val isZero = if (amountTextField.isFiatValue) decimalFiatValue.isNullOrZero() else decimalCryptoValue.isZero()
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(reduceAmountBy = null),
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance && !isZero,
amountTextField = amountTextField.copy(

View file

@ -37,6 +37,7 @@ internal class SendAmountFieldMaxAmountConverter(
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty()
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(reduceAmountBy = null),
amountState = amountState.copy(
isPrimaryButtonEnabled = true,
amountTextField = amountTextField.copy(

View file

@ -42,6 +42,7 @@ internal sealed class SendTextField {
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean,
) : SendTextField()
data class RecipientMemo(
@ -54,6 +55,7 @@ internal sealed class SendTextField {
val error: TextReference? = null,
val disabledText: TextReference,
val isEnabled: Boolean,
val isValuePasted: Boolean,
) : SendTextField()
data class CustomFee(

View file

@ -82,6 +82,7 @@ internal object FeeStatePreviewData {
isFeeApproximate = false,
notifications = persistentListOf(),
isCustomSelected = false,
isFeeConvertibleToFiat = true,
)
val feeChoosableState = feeState.copy(

View file

@ -28,6 +28,7 @@ internal object RecipientStatePreviewData {
label = stringReference("Recipient"),
isError = false,
error = null,
isValuePasted = false,
),
memoTextField = SendTextField.RecipientMemo(
value = "",
@ -39,6 +40,7 @@ internal object RecipientStatePreviewData {
error = null,
disabledText = stringReference("Already included in the entered address"),
isEnabled = true,
isValuePasted = false,
),
recent = persistentListOf(),
wallets = persistentListOf(),

View file

@ -36,7 +36,7 @@ internal object SendClickIntentsStub : SendClickIntents {
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) {}
override fun onRecipientMemoValueChange(value: String) {}
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {}
override fun feeReload() {}
@ -65,7 +65,8 @@ internal object SendClickIntentsStub : SendClickIntents {
reduceAmountByDiff: BigDecimal?,
reduceAmountTo: BigDecimal?,
clazz: Class<out SendNotification>,
) {}
) {
}
override fun onNotificationCancel(clazz: Class<out SendNotification>) {}
}

View file

@ -0,0 +1,195 @@
package com.tangem.features.send.impl.presentation.state.recipient
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.transaction.error.ValidateAddressError
import com.tangem.domain.transaction.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.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
import timber.log.Timber
internal class RecipientSendFactory(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val isUtxoConsolidationAvailableProvider: Provider<Boolean>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
) {
private val recipientWalletListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientWalletListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
isUtxoConsolidationAvailableProvider = isUtxoConsolidationAvailableProvider,
)
}
private val recipientHistoryListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientHistoryListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
fun onLoadedWalletsList(wallets: List<AvailableWallet?>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
wallets = recipientWalletListStateConverter.convert(wallets),
),
)
}
fun onLoadedHistoryList(txHistory: List<TxHistoryItem>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
recent = recipientHistoryListStateConverter.convert(txHistory),
),
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false, isValuePasted: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
addressTextField = recipientState.addressTextField.copy(value = value, isValuePasted = isValuePasted),
memoTextField = recipientState.memoTextField?.copy(isEnabled = !isXAddress),
),
)
}
fun getOnRecipientAddressValidState(
value: String,
maybeValidAddress: Either<ValidateAddressError, Unit>,
): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = recipientState.memoTextField?.value.orEmpty(),
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && maybeValidAddress.isRight(),
isValidating = false,
addressTextField = recipientState.addressTextField.copy(
error = maybeValidAddress.fold(
ifLeft = {
when (it) {
ValidateAddressError.InvalidAddress -> resourceReference(
R.string.send_recipient_address_error,
)
ValidateAddressError.AddressInWallet -> resourceReference(
R.string.send_error_address_same_as_wallet,
)
else -> null
}
},
ifRight = { null },
),
isError = value.isNotEmpty() && maybeValidAddress.isLeft(),
),
),
)
}
fun getOnRecipientAddressValidationStarted(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(isValidating = true),
)
}
fun getOnRecipientMemoValueChange(value: String, isValuePasted: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = value,
isValuePasted = isValuePasted,
),
),
)
}
fun getOnRecipientMemoValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = value,
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress,
isValidating = false,
memoTextField = recipientState.memoTextField?.copy(
isError = value.isNotEmpty() && !isValidMemo,
isEnabled = true,
),
),
)
}
fun getOnXAddressMemoState(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = "",
isEnabled = false,
),
),
)
}
fun getHiddenRecentListState(isNotValid: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
recent = recipientState.recent.map { recent ->
recent.copy(isVisible = isNotValid && (recent.isLoading || recent.title != TextReference.EMPTY))
}.toPersistentList(),
wallets = recipientState.wallets.map { wallet ->
wallet.copy(isVisible = isNotValid && (wallet.isLoading || wallet.title != TextReference.EMPTY))
}.toPersistentList(),
),
)
}
}

View file

@ -24,6 +24,7 @@ internal class SendRecipientAddressFieldConverter(
error = resourceReference(R.string.send_recipient_address_error),
placeholder = resourceReference(R.string.send_enter_address_field),
label = resourceReference(R.string.send_recipient),
isValuePasted = false,
)
}
}

View file

@ -58,6 +58,7 @@ internal class SendRecipientMemoFieldConverter(
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
isValuePasted = false,
)
}

View file

@ -1,16 +1,22 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_DEFAULT_COUNT
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_KEY_TAG
import com.tangem.features.send.impl.presentation.state.recipient.utils.emptyListState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientWalletListConverter :
internal class SendRecipientWalletListConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val isUtxoConsolidationAvailableProvider: Provider<Boolean>,
) :
Converter<List<AvailableWallet?>, PersistentList<SendRecipientListContent>> {
override fun convert(value: List<AvailableWallet?>): PersistentList<SendRecipientListContent> {
return value.filterWallets().ifEmpty {
@ -20,8 +26,18 @@ internal class SendRecipientWalletListConverter :
private fun List<AvailableWallet?>.filterWallets(): PersistentList<SendRecipientListContent> {
var walletsCounter = 0
val currentAddress: String = runCatching {
cryptoCurrencyStatusProvider().value.networkAddress?.defaultAddress?.value
}.getOrNull().orEmpty()
return this.filterNotNull()
.filter { it.address.isNotBlank() }
.filter {
val isCoin = it.cryptoCurrency is CryptoCurrency.Coin
val isNotSameAddress = it.address != currentAddress
val isNotBlankAddress = it.address.isNotBlank()
isNotBlankAddress && isCoin && (isNotSameAddress || isUtxoConsolidationAvailableProvider())
}
.groupBy { item -> item.name }
.values.map { wallets ->
val groupedByWallet = wallets.groupBy { it.userWalletId }

View file

@ -29,16 +29,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.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.Amount
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
import com.tangem.features.send.impl.presentation.utils.getFiatString
import com.tangem.features.send.impl.presentation.utils.getCryptoReference
@Composable
internal fun SendNavigationButtons(
@ -175,23 +174,36 @@ private fun SendingText(
val sendingFiat = if (uiState.isSubtracted) {
fiatAmount?.value
} else {
feeFiat?.let { fiatAmount?.value?.plus(it) }
if (feeState?.isFeeConvertibleToFiat == true) {
feeFiat?.let { fiatAmount?.value?.plus(it) }
} else {
fiatAmount?.value
}
}
if (feeFiat != null && sendingFiat != null) {
val sendingValue = getFiatFormatted(
value = sendingFiat,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val feeValue = getFiatString(
value = feeState.fee?.amount?.value,
rate = feeState.rate,
appCurrency = feeState.appCurrency,
val sendingValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = sendingFiat,
fiatCurrencySymbol = feeState.appCurrency.symbol,
fiatCurrencyCode = feeState.appCurrency.code,
)
val feeValue = if (feeState.isFeeConvertibleToFiat) {
getFiatString(
value = feeState.fee?.amount?.value,
rate = feeState.rate,
appCurrency = feeState.appCurrency,
)
} else {
getCryptoReference(feeState.fee?.amount, feeState.isFeeApproximate)?.resolveReference().orEmpty()
}
val textResource = remember(uiState) {
resourceReference(
id = R.string.send_summary_transaction_description,
id = if (feeState.isFeeConvertibleToFiat) {
R.string.send_summary_transaction_description
} else {
R.string.send_summary_transaction_description_no_fiat_fee
},
formatArgs = wrappedList(sendingValue, feeValue),
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
@ -12,15 +13,21 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
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.state.previewdata.ConfirmStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendStatesPreviewData
import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
@ -182,4 +189,49 @@ private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentS
}
}
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 736)
@Preview(showBackground = true, widthDp = 360, heightDp = 736, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendScreen_Preview(@PreviewParameter(SendScreenPreviewProvider::class) data: SendScreenPreview) {
TangemThemePreview {
SendScreen(
uiState = data.uiState,
currentState = data.currentState,
)
}
}
private class SendScreenPreviewProvider : PreviewParameterProvider<SendScreenPreview> {
override val values: Sequence<SendScreenPreview>
get() = sequenceOf(
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Recipient, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Amount, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Send, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.EditFee, isFromConfirmation = true),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState.copy(sendState = ConfirmStatePreviewData.sendDoneState),
currentState = SendUiCurrentScreen(type = SendUiStateType.Send, isFromConfirmation = false),
),
)
}
private data class SendScreenPreview(
val uiState: SendUiState,
val currentState: SendUiCurrentScreen,
)
// endregion

View file

@ -94,19 +94,20 @@ private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig, i
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier.size(TangemTheme.dimens.size18)
.padding(horizontal = TangemTheme.dimens.spacing1)
if (button.isFiat) {
FiatIcon(
url = button.iconUrl,
size = TangemTheme.dimens.size18,
isGrayscale = !isSegmentedButtonsEnabled,
modifier = Modifier.size(TangemTheme.dimens.size18),
modifier = iconModifier,
)
} else if (button.iconState != null) {
TokenIcon(
state = button.iconState,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size18),
modifier = iconModifier,
)
}
Text(

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.amount
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -8,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.previewdata.AmountStatePreviewData
@ -42,12 +44,13 @@ internal fun SendAmountContent(
}
// region Preview
@Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AmountFieldPreview_Light(
@PreviewParameter(AmountStatePreviewProvider::class) amountState: SendStates.AmountState,
private fun SendAmountContentPreview(
@PreviewParameter(SendAmountContentPreviewProvider::class) amountState: SendStates.AmountState,
) {
TangemTheme {
TangemThemePreview {
SendAmountContent(
amountState = amountState,
isBalanceHiding = false,
@ -56,21 +59,7 @@ private fun AmountFieldPreview_Light(
}
}
@Preview
@Composable
private fun AmountFieldPreview_Dark(
@PreviewParameter(AmountStatePreviewProvider::class) amountState: SendStates.AmountState,
) {
TangemTheme(isDark = true) {
SendAmountContent(
amountState = amountState,
isBalanceHiding = false,
clickIntents = SendClickIntentsStub,
)
}
}
private class AmountStatePreviewProvider : PreviewParameterProvider<SendStates.AmountState> {
private class SendAmountContentPreviewProvider : PreviewParameterProvider<SendStates.AmountState> {
override val values: Sequence<SendStates.AmountState>
get() = sequenceOf(
AmountStatePreviewData.amountState,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.fee
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
@ -8,10 +9,16 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
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.previewdata.FeeStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendClickIntentsStub
import com.tangem.features.send.impl.presentation.ui.common.notifications
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@ -75,4 +82,30 @@ internal fun LazyListScope.customFee(
.padding(top = TangemTheme.dimens.spacing12),
)
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendSpeedAndFeeContent_Preview(
@PreviewParameter(FeeStatePreviewProvider::class) feeState: SendStates.FeeState,
) {
TangemThemePreview {
SendSpeedAndFeeContent(
state = feeState,
clickIntents = SendClickIntentsStub,
)
}
}
private class FeeStatePreviewProvider : PreviewParameterProvider<SendStates.FeeState> {
override val values: Sequence<SendStates.FeeState>
get() = sequenceOf(
FeeStatePreviewData.feeState,
FeeStatePreviewData.feeChoosableState,
FeeStatePreviewData.feeCustomState,
FeeStatePreviewData.errorFeeState,
)
}
// endregion

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.fee
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@ -18,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@ -107,21 +109,12 @@ private fun FooterText(onReadMoreClick: () -> Unit) {
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendSpeedSelectorPreview_Light(
private fun SendSpeedSelectorPreview(
@PreviewParameter(SendSpeedSelectorPreviewProvider::class) feeState: SendStates.FeeState,
) {
TangemTheme {
SendSpeedSelector(state = feeState, clickIntents = SendClickIntentsStub)
}
}
@Preview
@Composable
private fun SendSpeedSelectorPreview_Dark(
@PreviewParameter(SendSpeedSelectorPreviewProvider::class) feeState: SendStates.FeeState,
) {
TangemTheme(isDark = true) {
TangemThemePreview {
SendSpeedSelector(state = feeState, clickIntents = SendClickIntentsStub)
}
}

View file

@ -54,7 +54,11 @@ internal fun SendSpeedSelectorItem(
onSelect = onSelect,
modifier = modifier,
preDot = getCryptoReference(amount, state.isFeeApproximate),
postDot = getFiatReference(amount?.value, state.rate, state.appCurrency),
postDot = if (state.isFeeConvertibleToFiat) {
getFiatReference(amount?.value, state.rate, state.appCurrency)
} else {
null
},
ellipsizeOffset = amount?.currencySymbol?.length,
isSelected = content?.selectedFee == feeType,
showDivider = showDivider,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
@ -27,6 +28,7 @@ import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
@ -183,28 +185,12 @@ private fun ListItemLoading(modifier: Modifier = Modifier) {
// region preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ListItemWithIconPreview_Light(
private fun ListItemWithIconPreview(
@PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig,
) {
TangemTheme {
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
subtitleEndOffset = config.subtitleEndOffset,
subtitleIconRes = config.iconRes,
onClick = {},
isLoading = config.isLoading,
)
}
}
@Preview
@Composable
private fun ListItemWithIconPreview_Dark(
@PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig,
) {
TangemTheme(isDark = true) {
TangemThemePreview {
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,

View file

@ -25,6 +25,7 @@ import com.tangem.common.Strings.STARS
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
@ -70,7 +71,7 @@ internal fun SendRecipientContent(
)
memoField(
memoField = memoField,
onMemoChange = clickIntents::onRecipientMemoValueChange,
onMemoChange = { clickIntents.onRecipientMemoValueChange(it, true) },
)
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
@ -117,6 +118,7 @@ private fun LazyListScope.addressItem(
isError = isError,
isLoading = isValidating,
error = address.error,
isValuePasted = address.isValuePasted,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
@ -143,6 +145,7 @@ private fun LazyListScope.memoField(memoField: SendTextField.RecipientMemo?, onM
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,
isValuePasted = memoField.isValuePasted,
)
}
}
@ -258,7 +261,7 @@ private fun AnimateRecentAppearance(isVisible: Boolean, content: @Composable ()
private fun SendRecipientContent_Preview(
@PreviewParameter(SendRecipientContentPreviewProvider::class) recipientState: SendStates.RecipientState,
) {
TangemTheme(isDark = false) {
TangemThemePreview {
SendRecipientContent(
uiState = recipientState,
clickIntents = SendClickIntentsStub,

View file

@ -32,6 +32,7 @@ internal fun TextFieldWithPaste(
error: TextReference? = null,
isError: Boolean = false,
isReadOnly: Boolean = false,
isValuePasted: Boolean = false,
) {
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
@ -65,6 +66,7 @@ internal fun TextFieldWithPaste(
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
isValuePasted = isValuePasted,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8),

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -16,6 +17,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.presentation.state.SendStates
@ -54,10 +56,7 @@ internal fun AmountBlock(
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16,
),
.padding(TangemTheme.dimens.spacing16),
) {
TokenIcon(state = amountState.tokenIconState)
ResizableText(
@ -77,21 +76,17 @@ internal fun AmountBlock(
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing2,
),
.padding(top = TangemTheme.dimens.spacing8),
)
}
}
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AmountBlockPreview_Light(
@PreviewParameter(AmountBlockPreviewProvider::class) value: SendStates.AmountState,
) {
TangemTheme {
private fun AmountBlockPreview(@PreviewParameter(AmountBlockPreviewProvider::class) value: SendStates.AmountState) {
TangemThemePreview {
AmountBlock(
amountState = value,
isClickDisabled = false,
@ -101,21 +96,6 @@ private fun AmountBlockPreview_Light(
}
}
@Preview
@Composable
private fun AmountBlockPreview_Dark(
@PreviewParameter(AmountBlockPreviewProvider::class) value: SendStates.AmountState,
) {
TangemTheme(isDark = true) {
AmountBlock(
amountState = value,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)
}
}
private class AmountBlockPreviewProvider : PreviewParameterProvider<SendStates.AmountState> {
override val values: Sequence<SendStates.AmountState>
get() = sequenceOf(

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -15,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
@ -60,7 +62,11 @@ internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, o
titleRes = title,
iconRes = icon,
preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate),
postDot = getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency),
postDot = if (feeState.isFeeConvertibleToFiat) {
getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency)
} else {
null
},
ellipsizeOffset = feeAmount?.currencySymbol?.length,
isSelected = true,
showDivider = false,
@ -111,21 +117,10 @@ private fun BoxScope.FeeError(feeSelectorState: FeeSelectorState) {
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeBlockPreview_Light(@PreviewParameter(FeeBlockPreviewProvider::class) value: SendStates.FeeState) {
TangemTheme {
FeeBlock(
feeState = value,
isClickDisabled = true,
onClick = {},
)
}
}
@Preview
@Composable
private fun FeeBlockPreview_Dark(@PreviewParameter(FeeBlockPreviewProvider::class) value: SendStates.FeeState) {
TangemTheme(isDark = true) {
private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: SendStates.FeeState) {
TangemThemePreview {
FeeBlock(
feeState = value,
isClickDisabled = true,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -15,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
@ -97,26 +99,12 @@ private fun MemoBlock(memo: SendTextField.RecipientMemo?) {
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun RecipientBlockPreview_Light(
private fun RecipientBlockPreview(
@PreviewParameter(RecipientBlockPreviewProvider::class) value: SendStates.RecipientState,
) {
TangemTheme {
RecipientBlock(
recipientState = value,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)
}
}
@Preview
@Composable
private fun RecipientBlockPreview_Dark(
@PreviewParameter(RecipientBlockPreviewProvider::class) value: SendStates.RecipientState,
) {
TangemTheme(isDark = true) {
TangemThemePreview {
RecipientBlock(
recipientState = value,
isClickDisabled = true,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.ExperimentalFoundationApi
@ -20,10 +21,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.previewdata.ConfirmStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendStatesPreviewData
import com.tangem.features.send.impl.presentation.ui.common.notifications
import kotlinx.coroutines.delay
@ -133,4 +140,25 @@ private fun LazyListScope.tapHelp(isDisplay: Boolean, modifier: Modifier = Modif
}
}
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendContent_Preview(@PreviewParameter(SendContentPreviewProvider::class) uiState: SendUiState) {
TangemThemePreview {
SendContent(
uiState = uiState,
)
}
}
private class SendContentPreviewProvider : PreviewParameterProvider<SendUiState> {
override val values: Sequence<SendUiState>
get() = sequenceOf(
SendStatesPreviewData.uiState,
SendStatesPreviewData.uiState.copy(sendState = ConfirmStatePreviewData.sendDoneState),
)
}
// endregion

View file

@ -8,11 +8,8 @@ import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.BigDecimalFormatter.EMPTY_BALANCE_SIGN
import com.tangem.domain.appcurrency.model.AppCurrency
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? {
if (amount == null) return null
@ -37,27 +34,9 @@ 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)
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 = currencyCode,
fiatCurrencySymbol = currencySymbol,
),
)
}
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = value,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
)
}
return BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}

View file

@ -39,7 +39,7 @@ internal interface SendClickIntents {
// region Recipient
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null)
fun onRecipientMemoValueChange(value: String)
fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean = false)
// endregion
// region Fee

View file

@ -4,10 +4,10 @@ import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastDistinctBy
import androidx.lifecycle.*
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -30,18 +30,14 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.error.ValidateAddressError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
@ -53,12 +49,10 @@ import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory
import com.tangem.features.send.impl.presentation.state.confirm.SendNotificationFactory
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.recipient.RecipientSendFactory
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.DelayedWork
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.coroutines.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
@ -74,11 +68,10 @@ import kotlin.properties.Delegates
internal class SendViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
@ -99,7 +92,9 @@ internal class SendViewModel @Inject constructor(
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase,
private val isUtxoConsolidationAvailableUseCase: IsUtxoConsolidationAvailableUseCase,
@DelayedWork private val coroutineScope: CoroutineScope,
validateTransactionUseCase: ValidateTransactionUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
@ -133,10 +128,17 @@ internal class SendViewModel @Inject constructor(
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
validateWalletMemoUseCase = validateWalletMemoUseCase,
isTapHelpPreviewEnabledProvider = Provider { isTapHelpPreviewEnabled },
)
private val recipientStateFactory = RecipientSendFactory(
stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
isUtxoConsolidationAvailableProvider = Provider { isUtxoConsolidationAvailable },
validateWalletMemoUseCase = validateWalletMemoUseCase,
)
private val amountStateFactory = AmountStateFactory(
stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState },
@ -178,6 +180,7 @@ internal class SendViewModel @Inject constructor(
clickIntents = this,
analyticsEventHandler = analyticsEventHandler,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
validateTransactionUseCase = validateTransactionUseCase,
)
private val sendScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
@ -196,6 +199,7 @@ internal class SendViewModel @Inject constructor(
private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList()
private var isAmountSubtractAvailable: Boolean = false
private var isUtxoConsolidationAvailable: Boolean = false
private var isTapHelpPreviewEnabled: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -241,11 +245,12 @@ internal class SendViewModel @Inject constructor(
}
private fun subscribeOnCurrencyStatusUpdates() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
checkIfSubtractAvailable()
checkIfUtxoConsolidationAvailable()
val isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
val isMultiCurrency = wallet.isMultiCurrency
@ -255,9 +260,7 @@ internal class SendViewModel @Inject constructor(
)
},
ifLeft = {
uiState = eventStateFactory.getGenericErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
showErrorAlert()
return@launch
},
)
@ -275,73 +278,62 @@ internal class SendViewModel @Inject constructor(
.saveIn(balanceHidingJobHolder)
}
// TODO [REDACTED_JIRA]
private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean, isMultiCurrency: Boolean) {
if (cryptoCurrency is CryptoCurrency.Coin) {
getCurrencyStatusUpdates(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
).onEach { currencyStatus ->
currencyStatus.onRight {
onDataLoaded(
currencyStatus = it,
coinCurrencyStatus = it,
feeCurrencyStatus = getFeeCurrencyStatusSync(it, isMultiCurrency),
)
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
private suspend fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean, isMultiCurrency: Boolean) {
val maybeCurrencyStatus = getCurrencyStatus(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
)
val maybeCoinStatus = if (cryptoCurrency is CryptoCurrency.Coin) {
maybeCurrencyStatus
} else {
combine(
flow = getCoinCurrencyStatusUpdates(isSingleWalletWithToken),
flow2 = getCurrencyStatusUpdates(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
),
) { maybeCoinStatus, maybeCurrencyStatus ->
if (maybeCoinStatus.isRight() && maybeCurrencyStatus.isRight()) {
val currencyStatus = maybeCurrencyStatus.getOrElse { error("Currency status is unreachable") }
val coinStatus = maybeCoinStatus.getOrElse { error("Coin status is unreachable") }
onDataLoaded(
currencyStatus = currencyStatus,
coinCurrencyStatus = coinStatus,
feeCurrencyStatus = getFeeCurrencyStatusSync(currencyStatus, isMultiCurrency),
)
}
getCoinCurrencyStatusUpdates(isSingleWalletWithToken)
}
if (maybeCoinStatus.isRight() && maybeCurrencyStatus.isRight()) {
val currencyStatus = maybeCurrencyStatus.getOrElse {
showErrorAlert()
return Timber.e("Currency status is unreachable")
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
val coinStatus = maybeCoinStatus.getOrElse {
showErrorAlert()
return Timber.e("Coin status is unreachable")
}
onDataLoaded(
currencyStatus = currencyStatus,
coinCurrencyStatus = coinStatus,
feeCurrencyStatus = getFeeCurrencyStatusSync(currencyStatus, isMultiCurrency),
)
} else {
showErrorAlert()
}
}
private fun getTapHelpPreviewAvailability() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
isTapHelpPreviewEnabled = isSendTapHelpEnabledUseCase().getOrElse { false }
}
}
private fun getCoinCurrencyStatusUpdates(isSingleWalletWithToken: Boolean) = getNetworkCoinStatusUseCase(
userWalletId = userWalletId,
networkId = cryptoCurrency.network.id,
derivationPath = cryptoCurrency.network.derivationPath,
isSingleWalletWithTokens = isSingleWalletWithToken,
).conflate().distinctUntilChanged()
private suspend fun getCoinCurrencyStatusUpdates(isSingleWalletWithToken: Boolean) = getNetworkCoinStatusUseCase
.invokeSync(
userWalletId = userWalletId,
networkId = cryptoCurrency.network.id,
derivationPath = cryptoCurrency.network.derivationPath,
isSingleWalletWithTokens = isSingleWalletWithToken,
)
private fun getCurrencyStatusUpdates(
private suspend fun getCurrencyStatus(
isSingleWalletWithToken: Boolean,
isMultiCurrency: Boolean,
): Flow<Either<CurrencyStatusError, CryptoCurrencyStatus>> {
): Either<CurrencyStatusError, CryptoCurrencyStatus> {
return if (isMultiCurrency) {
getCurrencyStatusUpdatesUseCase(
getCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
cryptoCurrencyId = cryptoCurrency.id,
isSingleWalletWithTokens = isSingleWalletWithToken,
).conflate().distinctUntilChanged()
)
} else {
getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId)
getCryptoCurrencyStatusSyncUseCase(userWalletId = userWalletId)
}
}
@ -399,30 +391,28 @@ internal class SendViewModel @Inject constructor(
private fun getWalletsAndRecent() {
getUserWallets()
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
getTxHistory()
}
}
private fun getUserWallets() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
runCatching {
getWalletsUseCase.invokeSync()
?.toAvailableWallets()
.orEmpty()
waitForDelay(delay = RECENT_LOAD_DELAY) {
getWalletsUseCase.invokeSync()
.toAvailableWallets()
}
}.onSuccess { result ->
userWallets = result
uiState = stateFactory.onLoadedWalletsList(wallets = userWallets)
uiState = recipientStateFactory.onLoadedWalletsList(wallets = userWallets)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
uiState = recipientStateFactory.onLoadedWalletsList(wallets = emptyList())
}
}
}
private suspend fun List<UserWallet>.toAvailableWallets(): List<AvailableWallet> {
val currentAddress: String = kotlin.runCatching {
cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
}.getOrNull().orEmpty()
return filterNot { it.isLocked }
.mapNotNull { wallet ->
val addresses = if (!wallet.isMultiCurrency) {
@ -436,26 +426,26 @@ internal class SendViewModel @Inject constructor(
} else {
getNetworkAddressesUseCase.invokeSync(wallet.walletId, cryptoCurrency.network)
}
addresses
?.filter { it.address != currentAddress }
?.map { (cryptoCurrency, address) ->
AvailableWallet(
name = wallet.name,
address = address,
cryptoCurrency = cryptoCurrency,
userWalletId = wallet.walletId,
)
}?.fastDistinctBy { it.address }
addresses?.map { (cryptoCurrency, address) ->
AvailableWallet(
name = wallet.name,
address = address,
cryptoCurrency = cryptoCurrency,
userWalletId = wallet.walletId,
)
}
}.flatten()
}
private suspend fun getTxHistory() {
val txHistoryList = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
currency = cryptoCurrency,
pageSize = RECENT_TX_SIZE,
).getOrElse { emptyList() }
uiState = stateFactory.onLoadedHistoryList(txHistory = txHistoryList)
val txHistoryList = waitForDelay(delay = RECENT_LOAD_DELAY) {
getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
currency = cryptoCurrency,
pageSize = RECENT_TX_SIZE,
).getOrElse { emptyList() }
}
uiState = recipientStateFactory.onLoadedHistoryList(txHistory = txHistoryList)
}
private fun onStateActive() {
@ -592,7 +582,7 @@ internal class SendViewModel @Inject constructor(
}
private fun cancelFeeRequest() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
feeJobHolder.cancel()
}
}
@ -613,7 +603,7 @@ internal class SendViewModel @Inject constructor(
)
}
// endregion
// endregion
// region amount state clicks
override fun onCurrencyChangeClick(isFiat: Boolean) {
@ -639,54 +629,59 @@ internal class SendViewModel @Inject constructor(
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) {
viewModelScope.launch {
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.onRecipientAddressValueChange(value)
uiState = stateFactory.getOnRecipientAddressValidationStarted()
uiState = recipientStateFactory.onRecipientAddressValueChange(value, isValuePasted = type != null)
uiState = recipientStateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(value)
uiState = stateFactory.getOnRecipientAddressValidState(value, isValidAddress)
type?.let { analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress)) }
autoNextFromRecipient(type, isValidAddress)
uiState = recipientStateFactory.getOnRecipientAddressValidState(value, isValidAddress)
type?.let {
analyticsEventHandler.send(
SendAnalyticEvents.AddressEntered(
it,
isValidAddress.isRight(),
),
)
}
autoNextFromRecipient(type, isValidAddress.isRight())
}
}.saveIn(addressValidationJobHolder)
}
override fun onRecipientMemoValueChange(value: String) {
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {
viewModelScope.launch {
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.getOnRecipientMemoValueChange(value)
uiState = stateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
uiState = recipientStateFactory.getOnRecipientMemoValueChange(value, isValuePasted)
uiState = recipientStateFactory.getOnRecipientAddressValidationStarted()
val recipientState = uiState.getRecipientState(stateRouter.isEditState)
val maybeValidAddress = validateAddress(recipientState?.addressTextField?.value.orEmpty())
uiState = recipientStateFactory.getOnRecipientMemoValidState(value, maybeValidAddress.isRight())
}
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean = runCatching {
val isValidAddress = validateWalletAddressUseCase(
private suspend fun validateAddress(value: String): Either<ValidateAddressError, Unit> = runCatching {
val maybeValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
address = value,
).getOrElse { false }
val isAddressInWallet = cryptoCurrencyStatus.value.networkAddress?.availableAddresses
?.any { it.value == value } ?: true
onEnteredValidAddress(isValidAddress, isAddressInWallet)
return isValidAddress
}.getOrElse { false }
currencyAddress = cryptoCurrencyStatus.value.networkAddress?.availableAddresses,
)
onEnteredValidAddress(maybeValidAddress.isLeft())
maybeValidAddress
}.getOrElse { ValidateAddressError.DataError(it).left() }
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
uiState = stateFactory.onRecipientAddressValueChange(value, isXAddress = true)
uiState = stateFactory.getOnXAddressMemoState()
uiState =
recipientStateFactory.onRecipientAddressValueChange(value, isXAddress = true, isValuePasted = true)
uiState = recipientStateFactory.getOnXAddressMemoState()
val isValidAddress = validateAddress(decodedAddress.address)
uiState = stateFactory.getOnRecipientAddressValidState(decodedAddress.address, isValidAddress)
uiState = recipientStateFactory.getOnRecipientAddressValidState(decodedAddress.address, isValidAddress)
true
} ?: false
}
private fun onEnteredValidAddress(isValidAddress: Boolean, isAddressInWallet: Boolean) {
uiState = stateFactory.getHiddenRecentListState(
isAddressInWallet = isAddressInWallet,
isValidAddress = isValidAddress,
)
private fun onEnteredValidAddress(isNotValid: Boolean) {
uiState = recipientStateFactory.getHiddenRecentListState(isNotValid = isNotValid)
}
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean) {
@ -722,7 +717,7 @@ internal class SendViewModel @Inject constructor(
}
private fun loadFee() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
val isShowStatus = uiState.feeState?.fee == null
if (isShowStatus) {
uiState = feeStateFactory.onFeeOnLoadingState()
@ -756,6 +751,13 @@ internal class SendViewModel @Inject constructor(
)
}
private suspend fun checkIfUtxoConsolidationAvailable() {
isUtxoConsolidationAvailable = isUtxoConsolidationAvailableUseCase.invokeSync(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
}
private suspend fun callFeeUseCase(): Either<GetFeeError, TransactionFee>? {
val isFromConfirmation = stateRouter.currentState.value.isFromConfirmation
val amountState = uiState.getAmountState(isFromConfirmation) ?: return null
@ -860,7 +862,7 @@ internal class SendViewModel @Inject constructor(
reduceAmountBy = uiState.sendState?.reduceAmountBy ?: BigDecimal.ZERO,
)
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
createTransactionUseCase(
amount = receivingAmount.convertToAmount(cryptoCurrency),
fee = fee,
@ -914,10 +916,14 @@ internal class SendViewModel @Inject constructor(
val recipientState = uiState.getRecipientState(stateRouter.isEditState) ?: return
val destinationAddress = recipientState.addressTextField.value
val maybeUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
val receivingUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
viewModelScope.launch(dispatchers.io) {
addCryptoCurrenciesUseCase(userWalletId = maybeUserWallet.userWalletId, currency = cryptoCurrency)
viewModelScope.launch {
addCryptoCurrenciesUseCase(
userWalletId = receivingUserWallet.userWalletId,
cryptoCurrency = cryptoCurrency,
network = receivingUserWallet.cryptoCurrency.network,
)
}
}
@ -949,7 +955,7 @@ internal class SendViewModel @Inject constructor(
val noErrorNotifications = sendState.notifications.none { it is SendNotification.Error }
if (!isSuccess && noErrorNotifications) {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
val feeUpdatedState = callFeeUseCase()?.fold(
ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
@ -983,16 +989,23 @@ internal class SendViewModel @Inject constructor(
}
private fun setNeverToShowTapHelp() {
viewModelScope.launch(dispatchers.main) {
viewModelScope.launch {
neverShowTapHelpUseCase()
}
uiState = stateFactory.getHiddenTapHelpState()
}
private fun showErrorAlert() {
uiState = eventStateFactory.getGenericErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
}
// endregion
private companion object {
const val CHECK_FEE_UPDATE_DELAY = 60_000L
const val BALANCE_UPDATE_DELAY = 11_000L
const val RECENT_LOAD_DELAY = 500L
const val RECENT_TX_SIZE = 100
const val RU_LOCALE = "ru"