From bb0aa53982f6eb7a9fb8f66824fe9a403e848eef Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 20 Jun 2026 20:57:03 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../SendDestinationComponentParams.kt | 1 + .../entity/DestinationTextFieldUM.kt | 3 + features/send/impl/build.gradle.kts | 2 + .../send/send/DefaultSendComponent.kt | 12 +- .../send/send/confirm/model/ConfirmData.kt | 2 + .../success/ui/SendConfirmSuccessContent.kt | 2 +- .../send/sendnft/DefaultNFTSendComponent.kt | 10 ++ .../DefaultSendDestinationBlockComponent.kt | 3 + .../DefaultSendDestinationComponent.kt | 54 ++++++- .../analytics/EnterAddressSource.kt | 3 +- .../destination/model/SendDestinationModel.kt | 143 ++++++++++++++++-- .../SendDestinationContactTransformer.kt | 22 +++ .../destination/ui/DestinationBlock.kt | 53 +++++-- .../destination/ui/SendDestinationContent.kt | 11 ++ 14 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt index e4879499a7..ead4f35164 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt @@ -40,5 +40,6 @@ sealed class SendDestinationComponentParams { val blockClickEnableFlow: StateFlow, val predefinedValues: PredefinedValues, override val isAllowSelfSend: Boolean = false, + val isAddContactAvailable: Boolean = false, ) : SendDestinationComponentParams() } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt index 00dc80f599..943d1e5a93 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.api.subcomponents.destination.entity import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.utils.toBriefAddressFormat @@ -24,6 +25,8 @@ sealed class DestinationTextFieldUM { val isValuePasted: Boolean, // if value is human-readable address, this field contains the actual blockchain address val blockchainAddress: String? = null, + val contactName: String? = null, + val contactIcon: AccountIconUM.CryptoPortfolio? = null, ) : DestinationTextFieldUM() { val actualAddress: String diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index d5b2231051..d57e08c9b0 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.features.nft.api) implementation(projects.features.swapV2.api) implementation(projects.features.manageTokens.api) + implementation(projects.features.addressBook.api) /** Libs */ implementation(projects.libs.crypto) @@ -68,6 +69,7 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.addressBook) implementation(projects.domain.transaction) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index dc13b7b071..bae3465d9b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -28,6 +28,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.FeeSelectorBlockComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents @@ -52,12 +55,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList") internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, + private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + private val addressSelectorFactory: AddressSelectorComponent.Factory, + private val addressBookFeatureToggles: AddressBookFeatureToggles, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -176,6 +182,9 @@ internal class DefaultSendComponent @AssistedInject constructor( cryptoCurrency = params.currency, callback = model, ), + addressBookFeatureToggles = addressBookFeatureToggles, + contactsBlockFactory = contactsBlockFactory, + addressSelectorFactory = addressSelectorFactory, ) private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { @@ -261,6 +270,7 @@ internal class DefaultSendComponent @AssistedInject constructor( cryptoCurrency = cryptoCurrencyStatus.currency, blockClickEnableFlow = MutableStateFlow(true), predefinedValues = model.predefinedValues, + isAddContactAvailable = true, ), onResult = { }, onClick = {}, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt index 96f1544055..1d28efbbdd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt @@ -1,9 +1,11 @@ package com.tangem.features.send.send.confirm.model +import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.transaction.error.GetFeeError import java.math.BigDecimal +@Immutable data class ConfirmData( val enteredAmount: BigDecimal?, val reduceAmountBy: BigDecimal, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt index e0788a8bf3..598a87cf9d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt @@ -25,8 +25,8 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.common.ui.FeeBlockSuccess import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.impl.R +import com.tangem.features.send.send.ui.state.SendUM @Composable internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index eb6ed71a6a..abce364a1b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -18,6 +18,9 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams @@ -35,12 +38,16 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch +@Suppress("LongParameterList") internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, + private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + private val addressSelectorFactory: AddressSelectorComponent.Factory, + private val addressBookFeatureToggles: AddressBookFeatureToggles, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -145,6 +152,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( cryptoCurrency = model.cryptoCurrency, callback = model, ), + addressBookFeatureToggles = addressBookFeatureToggles, + contactsBlockFactory = contactsBlockFactory, + addressSelectorFactory = addressSelectorFactory, ) private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt index 90df4644fb..324a0ef93e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt @@ -39,12 +39,15 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle() + val isAddContactVisible by model.showAddContact.collectAsStateWithLifecycle() DestinationBlock( destinationUM = state, isClickDisabled = !isClickEnabled, isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, + showAddContact = isAddContactVisible, + onAddContactClick = model::onAddContactClick, ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index d798a85168..c6afc17f98 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -4,8 +4,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM @@ -18,18 +26,62 @@ import dagger.assisted.AssistedInject internal class DefaultSendDestinationComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendDestinationComponentParams.DestinationParams, + addressBookFeatureToggles: AddressBookFeatureToggles, + contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + addressSelectorFactory: AddressSelectorComponent.Factory, ) : SendDestinationComponent, AppComponentContext by appComponentContext { private val model: SendDestinationModel = getOrCreateModel(params = params) + private val contactsBlock: AddressBookContactsBlockComponent? by lazy { + if (addressBookFeatureToggles.isAddressBookEnabled) { + contactsBlockFactory.create( + context = child("send_contacts_block"), + params = AddressBookContactsBlockComponent.Params( + userWalletId = params.userWalletId, + network = params.cryptoCurrency.network, + queryFlow = model.addressQuery, + onContactClick = model::onContactClick, + onSeeAllClick = model::onSeeAllContactsClick, + ), + ) + } else { + null + } + } + + private val addressSelectorSlot = childSlot( + source = model.addressSelectorNavigation, + serializer = null, + key = "send_address_selector_slot", + handleBackButton = true, + childFactory = { contact, componentContext -> + addressSelectorFactory.create( + context = childByContext(componentContext), + params = AddressSelectorComponent.Params( + contact = contact, + onAddressSelected = model::applySelectedContact, + onDismiss = { model.addressSelectorNavigation.dismiss() }, + ), + ) + }, + ) + override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM) @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() + val selector by addressSelectorSlot.subscribeAsState() - SendDestinationContent(state = state, clickIntents = model, isBalanceHidden = isBalanceHidden) + SendDestinationContent( + state = state, + clickIntents = model, + isBalanceHidden = isBalanceHidden, + contactsBlock = contactsBlock, + ) + selector.child?.instance?.BottomSheet() } @AssistedFactory diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt index 911f500009..54c9e5e288 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt @@ -6,11 +6,12 @@ internal enum class EnterAddressSource { RecentAddress, InputField, MyWallets, + Contact, ; val isPasted: Boolean get() = this != InputField val isAutoNext: Boolean - get() = this == RecentAddress || this == MyWallets + get() = this == RecentAddress || this == MyWallets || this == Contact } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 2a14399fd0..0056c9c2ff 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -3,7 +3,12 @@ package com.tangem.features.send.subcomponents.destination.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import arrow.core.left +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.entity.AddressBookOpenMode +import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,8 +20,11 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress @@ -34,6 +42,9 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.SelectedContact import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.entity.PredefinedValues @@ -41,18 +52,12 @@ import com.tangem.features.send.api.subcomponents.destination.SendDestinationCom import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource -import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationAddressTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationMemoTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationPredefinedStateTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationRecentListTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationResultTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationStartedTransformer -import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory +import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource +import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents +import com.tangem.features.send.subcomponents.destination.model.transformers.* +import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -88,6 +93,8 @@ internal class SendDestinationModel @Inject constructor( private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase, private val sendDestinationAlertFactory: SendDestinationAlertFactory, private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, + getContactsUseCase: GetContactsUseCase, + contactSelectionListener: ContactSelectionListener, ) : Model(), SendDestinationClickIntents { private val params: SendDestinationComponentParams = paramsContainer.require() @@ -98,6 +105,36 @@ internal class SendDestinationModel @Inject constructor( private val cryptoCurrency = params.cryptoCurrency private val userWalletId = params.userWalletId + private val contacts: StateFlow> = getContactsUseCase(query = "", userWalletId = userWalletId) + .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) + + val addressSelectorNavigation = SlotNavigation() + val addressQuery: StateFlow = uiState + .map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() } + .distinctUntilChanged() + .stateIn(modelScope, SharingStarted.Eagerly, "") + + /** + * Whether to offer saving the recipient to the address book under the recipient block. Only for the success-screen + * block ([DestinationBlockParams.isAddContactAvailable]) and only when the recipient was NOT a contact and its + * `(address, network)` pair is not already saved in the current wallet's book. + */ + val showAddContact: StateFlow = + if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) { + combine(uiState, contacts) { state, contactList -> + val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false + if (address.contactName != null) return@combine false // sent via a contact + val networkId = cryptoCurrency.network.rawId + contactList.none { contact -> + contact.addressEntries.any { + it.networkId.value == networkId && it.address.equals(address.actualAddress, ignoreCase = true) + } + } + }.stateIn(modelScope, SharingStarted.Eagerly, false) + } else { + MutableStateFlow(false) + } + // In "Send with swap" flow, these are addresses in the destination network (not the actual sender addresses). // Self-send validation must be skipped for them, so use only with params.isAllowSelfSend. private val senderAddresses = MutableStateFlow>(emptyList()) @@ -110,6 +147,63 @@ internal class SendDestinationModel @Inject constructor( configDestinationNavigation() subscribeOnQRScannerResult() initialState() + resetContactOnEdit() + contactSelectionListener.resultFlow + .onEach(::applySelectedContact) + .launchIn(modelScope) + } + + private fun resetContactOnEdit() { + val params = params as? SendDestinationComponentParams.DestinationParams ?: return + params.currentRoute + .filter { it.isEditMode } + .onEach { + val content = uiState.value as? DestinationUM.Content ?: return@onEach + if (content.addressTextField.contactName != null) { + _uiState.update(SendDestinationContactTransformer(contactName = null, contactIcon = null)) + _uiState.update(SendDestinationAddressTransformer(address = "", isPasted = false)) + validate(address = "", memo = content.memoTextField?.value) + } + } + .launchIn(modelScope) + } + + fun onContactClick(contact: MatchedContact) { + val singleEntry = contact.entries.singleOrNull() + if (singleEntry != null) { + applySelectedContact(contact.toSelectedContact(singleEntry)) + } else { + addressSelectorNavigation.activate(contact) + } + } + + fun onSeeAllContactsClick() { + router.push( + AppRoute.AddressBook(AddressBookOpenMode.ContactSelection(networkId = cryptoCurrency.network.rawId)), + ) + } + + /** Opens the contact editor pre-filled with the sent address/network to save the recipient (success screen). */ + fun onAddContactClick() { + val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return + router.push( + AppRoute.AddressBook( + AddressBookOpenMode.WithContactCreation(address = address, networkId = cryptoCurrency.network.rawId), + ), + ) + } + + fun applySelectedContact(contact: SelectedContact) { + addressSelectorNavigation.dismiss() + _uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true)) + _uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon)) + + val isMemoSupported = (uiState.value as? DestinationUM.Content)?.memoTextField != null + val memo = contact.memo?.takeIf { isMemoSupported && it.isNotBlank() } + if (memo != null) { + _uiState.update(SendDestinationMemoTransformer(memo = memo, isPasted = true)) + } + validate(address = contact.address, memo = memo, type = EnterAddressSource.Contact) } private fun initialState() { @@ -371,6 +465,7 @@ internal class SendDestinationModel @Inject constructor( isMemoRequired = isMemoRequired, ), ) + recognizeContact(type = type, isValidAddress = addressValidationResult.isRight(), address = resolvedAddress) if (type != null) { autoNextFromRecipient( type = type, @@ -381,6 +476,34 @@ internal class SendDestinationModel @Inject constructor( }.saveIn(validationJobHolder) } + private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) { + if (type == null || type == EnterAddressSource.Contact) return + val recognized = if (isValidAddress) findContactByAddress(address) else null + _uiState.update(SendDestinationContactTransformer(recognized?.name, recognized?.icon)) + } + + private fun findContactByAddress(address: String): RecognizedContact? { + val networkId = cryptoCurrency.network.rawId + contacts.value.forEach { contact -> + val isMatch = contact.addressEntries.any { entry -> + entry.networkId.value == networkId && entry.address.equals(address, ignoreCase = true) + } + if (isMatch) { + return RecognizedContact( + name = contact.name.value, + // TODO([REDACTED_TASK_KEY]): take the color from the domain Contact once the data layer stores it. + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + ) + } + } + return null + } + + private data class RecognizedContact(val name: String, val icon: AccountIconUM.CryptoPortfolio) + private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { if (type.isAutoNext && isValidAddress && isValidMemo) { saveResult() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt new file mode 100644 index 0000000000..1dd67091a4 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.send.subcomponents.destination.model.transformers + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationContactTransformer( + private val contactName: String?, + private val contactIcon: AccountIconUM.CryptoPortfolio?, +) : Transformer { + + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy( + addressTextField = state.addressTextField.copy( + contactName = contactName, + contactIcon = contactIcon, + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt index 7897334853..12175f963f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt @@ -15,17 +15,21 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIcon +import com.tangem.core.ui.components.SecondaryButtonIconStart +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SendConfirmScreenTestTags -import com.tangem.features.send.impl.R import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.impl.R import kotlinx.collections.immutable.toImmutableList @Composable @@ -34,6 +38,8 @@ internal fun DestinationBlock( isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit, + showAddContact: Boolean = false, + onAddContactClick: () -> Unit = {}, ) { if (destinationUM !is DestinationUM.Content) return @@ -50,6 +56,16 @@ internal fun DestinationBlock( address = destinationUM.addressTextField, memo = destinationUM.memoTextField, ) + if (showAddContact) { + SecondaryButtonIconStart( + text = stringResourceSafe(com.tangem.core.ui.R.string.address_book_add_contact), + iconResId = com.tangem.core.ui.R.drawable.ic_plus_24, + onClick = onAddContactClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing12), + ) + } } } @@ -68,31 +84,42 @@ private fun AddressWithMemoBlock( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), ) { + val contactName = address.contactName + val contactIcon = address.contactIcon Column(modifier = Modifier.weight(1f)) { Text( - text = address.value, + text = contactName ?: address.value, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS), ) - val blockchainAddress = address.briefBlockchainAddress - if (!blockchainAddress.isNullOrBlank()) { + val recipient = if (contactName != null) address.value else address.briefBlockchainAddress + if (!recipient.isNullOrBlank()) { Text( - text = blockchainAddress, + text = recipient, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.testTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS), ) } } - IdentIcon( - address = address.value, - modifier = Modifier - .size(TangemTheme.dimens.size36) - .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) - .background(TangemTheme.colors.background.tertiary) - .testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), - ) + if (contactName != null && contactIcon != null) { + AccountIcon( + name = stringReference(contactName), + icon = contactIcon, + size = AccountIconSize.Medium, + modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), + ) + } else { + IdentIcon( + address = address.value, + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) + .background(TangemTheme.colors.background.tertiary) + .testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), + ) + } } if (memo != null && memo.value.isNotBlank()) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt index 1c539cd97b..84f2269749 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt @@ -30,6 +30,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.utils.GlobalMultipleClickPreventer +import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM @@ -47,6 +48,7 @@ internal fun SendDestinationContent( state: DestinationUM, clickIntents: SendDestinationClickIntents, isBalanceHidden: Boolean, + contactsBlock: AddressBookContactsBlockComponent? = null, ) { if (state !is DestinationUM.Content) return val recipients = state.recent @@ -115,6 +117,15 @@ internal fun SendDestinationContent( ) }, ) + if (contactsBlock != null && !state.isRecentHidden) { + item(key = "CONTACTS_BLOCK_KEY") { + contactsBlock.Content( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + ) + } + } item("SPACER_KEY") { SpacerH(16.dp) }