diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index a0dbd31b63..be1229b281 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -23,6 +23,8 @@ import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase @@ -61,6 +63,7 @@ internal class SendDestinationModel @Inject constructor( private val router: Router, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val validateWalletMemoUseCase: ValidateWalletMemoUseCase, + private val isMemoRequiredUseCase: IsMemoRequiredUseCase, private val getWalletsUseCase: GetWalletsUseCase, private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, @@ -295,6 +298,20 @@ internal class SendDestinationModel @Inject constructor( cryptoCurrency = cryptoCurrency, memo = memo.orEmpty(), ) + val resolvedAddress = + (addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress) + ?.blockchainAddress + ?: address + // Ripple X-Address already embeds the destination tag, so memo is irrelevant for it + val isXAddress = addressValidationResult.getOrNull() == AddressValidation.Success.ValidXAddress + val isMemoRequired = memo.isNullOrBlank() && + !isXAddress && + addressValidationResult.isRight() && + (uiState.value as? DestinationUM.Content)?.memoTextField != null && + isMemoRequiredUseCase( + network = cryptoCurrency.network, + destinationAddress = resolvedAddress, + ) if (type != null) { analyticsEventHandler.send( @@ -308,12 +325,17 @@ internal class SendDestinationModel @Inject constructor( } _uiState.update( SendDestinationValidationResultTransformer( - addressValidationResult, - memoValidationResult, + addressValidationResult = addressValidationResult, + memoValidationResult = memoValidationResult, + isMemoRequired = isMemoRequired, ), ) if (type != null) { - autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight()) + autoNextFromRecipient( + type = type, + isValidAddress = addressValidationResult.isRight(), + isValidMemo = isXAddress || memoValidationResult.isRight() && !isMemoRequired, + ) } }.saveIn(validationJobHolder) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt index 35cae2c471..93fdb5aba2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt @@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.ValidateMemoError +import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.v2.impl.R import com.tangem.utils.transformer.Transformer @@ -14,12 +15,14 @@ import kotlinx.collections.immutable.toPersistentList internal class SendDestinationValidationResultTransformer( private val addressValidationResult: AddressValidationResult, private val memoValidationResult: Either, + private val isMemoRequired: Boolean = false, ) : Transformer { override fun transform(prevState: DestinationUM): DestinationUM { val state = prevState as? DestinationUM.Content ?: return prevState + val shouldDisableMemo = shouldDisableMemo() val isValidAddress = addressValidationResult.isRight() - val isValidMemo = memoValidationResult.isRight() + val isValidMemo = shouldDisableMemo || memoValidationResult.isRight() val addressErrorText = addressValidationResult.mapLeft { error -> when (error) { @@ -30,23 +33,23 @@ internal class SendDestinationValidationResultTransformer( } }.leftOrNull() - val shouldDisableMemo = shouldDisableMemo() val blockchainAddress = (addressValidationResult.getOrNull() as? AddressValidation.Success.ValidNamedAddress)?.blockchainAddress - val memoField = state.memoTextField + val isMemoMissing = isMemoRequired && !shouldDisableMemo && state.memoTextField?.value.isNullOrBlank() return state.copy( isValidating = false, - isPrimaryButtonEnabled = isValidAddress && isValidMemo, + isPrimaryButtonEnabled = isValidAddress && isValidMemo && !isMemoMissing, addressTextField = state.addressTextField.copy( error = addressErrorText?.let(::resourceReference), isError = state.addressTextField.value.isNotEmpty() && !isValidAddress, blockchainAddress = blockchainAddress, ), - memoTextField = memoField?.copy( - value = memoField.value.takeIf { !shouldDisableMemo }.orEmpty(), - isError = memoField.value.isNotEmpty() && !isValidMemo, - isEnabled = !shouldDisableMemo, + memoTextField = buildMemoField( + memoField = state.memoTextField, + isValidMemo = isValidMemo, + isMemoMissing = isMemoMissing, + shouldDisableMemo = shouldDisableMemo, ), recent = state.recent.map { recent -> recent.copy(isVisible = !isValidAddress && (recent.isLoading || recent.title != TextReference.EMPTY)) @@ -58,6 +61,26 @@ internal class SendDestinationValidationResultTransformer( ) } + private fun buildMemoField( + memoField: DestinationTextFieldUM.RecipientMemo?, + isValidMemo: Boolean, + isMemoMissing: Boolean, + shouldDisableMemo: Boolean, + ): DestinationTextFieldUM.RecipientMemo? { + memoField ?: return null + val isMemoFormatError = memoField.value.isNotEmpty() && !isValidMemo + return memoField.copy( + value = memoField.value.takeIf { !shouldDisableMemo }.orEmpty(), + isError = isMemoFormatError || isMemoMissing, + error = if (isMemoMissing) { + resourceReference(R.string.send_validation_destination_tag_required_title) + } else { + resourceReference(R.string.send_memo_destination_tag_error) + }, + isEnabled = !shouldDisableMemo, + ) + } + /** Ripple X-Address contains memo, so memo field is unnecessary */ private fun shouldDisableMemo(): Boolean { return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress } diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt new file mode 100644 index 0000000000..38c6429a56 --- /dev/null +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt @@ -0,0 +1,174 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import androidx.compose.foundation.text.KeyboardOptions +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult +import com.tangem.domain.transaction.error.ValidateMemoError +import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationTextFieldUM +import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.v2.impl.R +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SendDestinationValidationResultTransformerTest { + + private val validAddress: AddressValidationResult = AddressValidation.Success.Valid.right() + private val invalidAddress: AddressValidationResult = AddressValidation.Error.InvalidAddress.left() + private val xAddress: AddressValidationResult = AddressValidation.Success.ValidXAddress.right() + + private val validMemo = Unit.right() + private val invalidMemo = ValidateMemoError.InvalidMemo.left() + + private val formatErrorRef = resourceReference(R.string.send_memo_destination_tag_error) + private val tagRequiredRef = resourceReference(R.string.send_validation_destination_tag_required_title) + + @Test + fun `GIVEN required memo is empty WHEN transform THEN tag required error shown and primary button disabled`() { + val result = transform(validAddress, validMemo, isMemoRequired = true, memo = "") + + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat(result.memoTextField?.isError).isTrue() + assertThat(result.memoTextField?.error).isEqualTo(tagRequiredRef) + } + + @Test + fun `GIVEN required memo is filled with valid value WHEN transform THEN primary button enabled`() { + val result = transform(validAddress, validMemo, isMemoRequired = true, memo = "123") + + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.memoTextField?.isError).isFalse() + } + + @Test + fun `GIVEN required memo filled with whitespace only WHEN transform THEN tag required error shown and primary button disabled`() { + val result = transform(validAddress, validMemo, isMemoRequired = true, memo = " ") + + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat(result.memoTextField?.isError).isTrue() + assertThat(result.memoTextField?.error).isEqualTo(tagRequiredRef) + } + + @Test + fun `GIVEN empty memo that is not required WHEN transform THEN field valid and primary button enabled`() { + val result = transform(validAddress, validMemo, isMemoRequired = false, memo = "") + + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.memoTextField?.isError).isFalse() + } + + @Test + fun `GIVEN entered memo with valid format WHEN transform THEN primary button enabled`() { + val result = transform(validAddress, validMemo, isMemoRequired = false, memo = "valid-memo") + + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.memoTextField?.isError).isFalse() + } + + @Test + fun `GIVEN entered memo with invalid format WHEN transform THEN format error shown and primary button disabled`() { + val result = transform(validAddress, invalidMemo, isMemoRequired = false, memo = "bad-memo") + + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat(result.memoTextField?.isError).isTrue() + assertThat(result.memoTextField?.error).isEqualTo(formatErrorRef) + } + + @Test + fun `GIVEN invalid memo after prior tag required state WHEN transform THEN format error shown`() { + val staleState = contentState(memo = "bad-memo").let { state -> + state.copy(memoTextField = state.memoTextField?.copy(error = tagRequiredRef)) + } + + val result = SendDestinationValidationResultTransformer( + addressValidationResult = validAddress, + memoValidationResult = invalidMemo, + isMemoRequired = false, + ).transform(staleState) as DestinationUM.Content + + assertThat(result.memoTextField?.isError).isTrue() + assertThat(result.memoTextField?.error).isEqualTo(formatErrorRef) + } + + @Test + fun `GIVEN invalid address WHEN transform THEN primary button disabled`() { + val result = transform(invalidAddress, validMemo, isMemoRequired = false, memo = "123") + + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN x-address with required memo WHEN transform THEN memo field disabled cleared and primary button enabled`() { + val result = transform(xAddress, validMemo, isMemoRequired = true, memo = "") + + assertThat(result.memoTextField?.isEnabled).isFalse() + assertThat(result.memoTextField?.isError).isFalse() + assertThat(result.memoTextField?.value).isEmpty() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN x-address with stale invalid memo WHEN transform THEN memo ignored field disabled and primary button enabled`() { + val result = transform(xAddress, invalidMemo, isMemoRequired = false, memo = "stale-memo") + + assertThat(result.memoTextField?.isEnabled).isFalse() + assertThat(result.memoTextField?.isError).isFalse() + assertThat(result.memoTextField?.value).isEmpty() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN network without memo field WHEN transform THEN memo field is null and primary button enabled`() { + val state = contentState(memo = "").copy(memoTextField = null) + + val result = SendDestinationValidationResultTransformer( + addressValidationResult = validAddress, + memoValidationResult = validMemo, + isMemoRequired = false, + ).transform(state) as DestinationUM.Content + + assertThat(result.memoTextField).isNull() + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + private fun transform( + address: AddressValidationResult, + memoResult: Either, + isMemoRequired: Boolean, + memo: String, + ): DestinationUM.Content = SendDestinationValidationResultTransformer( + addressValidationResult = address, + memoValidationResult = memoResult, + isMemoRequired = isMemoRequired, + ).transform(contentState(memo = memo)) as DestinationUM.Content + + private fun contentState(memo: String) = DestinationUM.Content( + isPrimaryButtonEnabled = false, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "0xRecipient", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.EMPTY, + label = TextReference.EMPTY, + isValuePasted = false, + ), + memoTextField = DestinationTextFieldUM.RecipientMemo( + value = memo, + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.EMPTY, + label = TextReference.EMPTY, + error = formatErrorRef, + disabledText = TextReference.EMPTY, + isEnabled = true, + isValuePasted = false, + ), + recent = persistentListOf(), + wallets = persistentListOf(), + networkName = "Ethereum", + isRecentHidden = false, + ) +} \ No newline at end of file