From 3fefb7bf52dee93ca1fbddea786ccc6fc2e7e55f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 15:16:50 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../domain/addressbook/model/ContactName.kt | 20 +- .../addressbook/model/ContactNameTest.kt | 39 ++- features/address-book/impl/build.gradle.kts | 1 + .../common/AddressBookChildFactory.kt | 3 + .../addressbook/common/ui/ContactRow.kt | 4 +- .../DefaultEditContactComponent.kt | 36 +- .../model/ContactAddressEntriesConverter.kt | 32 ++ .../editcontact/model/EditContactModel.kt | 227 ++++++++++++- .../state/EditContactStateController.kt | 15 + .../AddValidatedAddressTransformer.kt | 5 +- ...pdateEditContactInitialStateTransformer.kt | 2 + .../UpdateNameErrorTransformer.kt | 12 + .../UpdateSaveButtonTransformer.kt | 16 + .../UpdateWalletBlockTransformer.kt | 21 ++ .../converter/ContactNameErrorConverter.kt | 20 ++ .../editcontact/ui/EditContactContent.kt | 186 +++++++++-- .../editcontact/ui/state/EditContactUM.kt | 11 + .../list/ui/AddressBookListScreen.kt | 25 +- .../editcontact/model/EditContactModelTest.kt | 311 +++++++++++++++++- .../PortfolioSelectorComponent.kt | 9 + .../PortfolioSelectorModel.kt | 5 +- 21 files changed, 944 insertions(+), 56 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateNameErrorTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateSaveButtonTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateWalletBlockTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/converter/ContactNameErrorConverter.kt diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt index c31f1f70f7..37b7e98f0e 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt @@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.model import arrow.core.Either import arrow.core.raise.either import arrow.core.raise.ensure +import com.tangem.domain.addressbook.model.ContactName.Companion.invoke import kotlinx.serialization.Serializable /** @@ -31,11 +32,24 @@ data class ContactName private constructor(val value: String) { companion object { - const val MIN_LENGTH = 1 + private const val MIN_LENGTH = 1 const val MAX_LENGTH = 50 - /** Letters, numbers and spaces only β€” forbids emoji, new lines, tabs, special symbols and html/scripts. */ - private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$") + /** + * Allows letters of any locale (`\p{L}`) and their combining marks (`\p{M}`, which also covers emoji + * variation selectors and keycap marks), digits (`\p{N}`), a regular space, and emoji β€” symbols (`\p{So}`, + * including flags / regional indicators), emoji skin-tone modifiers (`\p{Sk}`) and the zero-width joiner + * (U+200D) used in emoji sequences. + * + * Everything else is rejected, which covers the forbidden set: line breaks, tabs and other control + * characters, invisible/format unicode (zero-width spaces, BOM, …), exotic spaces, and HTML/script symbols. + * + * The leading lookahead requires at least one visible "base" character (letter / digit / emoji symbol), so a + * name made up only of zero-width joiners, combining marks, modifiers or spaces (i.e. effectively invisible) + * is rejected. + */ + private val allowedPattern = + Regex("^(?=.*[\\p{L}\\p{N}\\p{So}])[\\p{L}\\p{M}\\p{N}\\p{So}\\p{Sk}\\u0020\\u200D]+$") operator fun invoke(value: String): Either = either { val trimmed = value.trim() diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt index 5fe112550d..f7d2b13308 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt @@ -39,8 +39,33 @@ class ContactNameTest { } @Test - fun `emoji is rejected`() { - assertThat(ContactName("Alice πŸ˜€").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + fun `simple emoji is accepted`() { + assertThat(ContactName("Alice πŸ˜€").isRight()).isTrue() + } + + @Test + fun `emoji-only name is accepted`() { + assertThat(ContactName("πŸ˜€").isRight()).isTrue() + } + + @Test + fun `flag emoji is accepted`() { + assertThat(ContactName("Team πŸ‡ΊπŸ‡Έ").isRight()).isTrue() + } + + @Test + fun `zwj emoji sequence is accepted`() { + assertThat(ContactName("Family πŸ‘¨β€πŸ‘©β€πŸ‘§").isRight()).isTrue() + } + + @Test + fun `emoji with variation selector is accepted`() { + assertThat(ContactName("Love ❀️").isRight()).isTrue() + } + + @Test + fun `non-latin letters are accepted`() { + assertThat(ContactName("ΠΠ»Ρ‘ΡˆΠ° ε€§ι˜ͺ").isRight()).isTrue() } @Test @@ -53,6 +78,16 @@ class ContactNameTest { assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) } + @Test + fun `zero-width space is rejected`() { + assertThat(ContactName("Ali\u200Bce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } + + @Test + fun `non-breaking space is rejected`() { + assertThat(ContactName("Ali\u00A0ce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } + @Test fun `html script is rejected`() { assertThat(ContactName("").leftOrNull()) diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index a3833531f8..5337d75bef 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /** Api */ implementation(projects.features.addressBook.api) + implementation(projects.features.commonFeatures.api) /** Domain */ implementation(projects.domain.account) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index 457ea265a1..3e989cde35 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -10,6 +10,7 @@ import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import kotlinx.collections.immutable.persistentListOf import javax.inject.Inject @@ -19,6 +20,7 @@ import javax.inject.Inject */ internal class AddressBookChildFactory @Inject constructor( private val addressSelectorFactory: AddressSelectorComponent.Factory, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, ) { fun createChild( @@ -43,6 +45,7 @@ internal class AddressBookChildFactory @Inject constructor( onBackClick = clickIntents::onEditContactBack, onAddAddressClick = clickIntents::onAddAddressClick, ), + portfolioSelectorComponentFactory = portfolioSelectorComponentFactory, ) AddressBookRoute.AddAddress -> DefaultAddAddressComponent( appComponentContext = context, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt index d154a91c57..ac474389f0 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt @@ -1,6 +1,7 @@ package com.tangem.features.addressbook.common.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIconUM @@ -15,8 +16,9 @@ import com.tangem.features.addressbook.list.ui.state.ContactUM import com.tangem.utils.StringsSigns @Composable -internal fun ContactRow(contact: ContactUM) { +internal fun ContactRow(contact: ContactUM, modifier: Modifier = Modifier) { TangemRow( + modifier = modifier, onClick = contact.onClick, verticalAlignment = TangemRowVerticalAlignment.Center, contentLead = TangemRowContentLead.Start, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt index 7fa44a861e..fd6c1c58c2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt @@ -5,29 +5,63 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.addressbook.model.ContactId import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.ui.EditContactContent import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import kotlinx.serialization.builtins.serializer internal class DefaultEditContactComponent( appComponentContext: AppComponentContext, params: Params, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, ) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: EditContactModel = getOrCreateModel(params) + private val portfolioSelectorSlot = childSlot( + source = model.portfolioSelectorNavigation, + serializer = Unit.serializer(), + handleBackButton = false, + childFactory = { _, componentContext -> portfolioSelectorChild(componentContext) }, + ) + + private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent = + portfolioSelectorComponentFactory.create( + context = childByContext(componentContext), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + bsCallback = model.portfolioSelectorCallback, + settings = PortfolioSelectorComponent.Settings(isWalletSelectionOnly = true), + ), + ) + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val selectorSlot by portfolioSelectorSlot.subscribeAsState() + BackHandler { + if (selectorSlot.child != null) { + model.portfolioSelectorCallback.onBack() + } else { + state.onCloseClick() + } + } EditContactContent( state = state, modifier = modifier, ) - BackHandler(onBack = state.onCloseClick) + selectorSlot.child?.instance?.BottomSheet() } data class Params( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt new file mode 100644 index 0000000000..a039134f1d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt @@ -0,0 +1,32 @@ +package com.tangem.features.addressbook.editcontact.model + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +import com.tangem.domain.models.network.Network +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import java.util.UUID + +internal class ContactAddressEntriesConverter { + + fun convert(addresses: List): List { + return addresses.flatMap { address -> + address.networkIds.map { rawId -> address.toAddressEntry(rawId) } + } + } + + private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry { + val blockchain = Blockchain.fromNetworkId(rawId) + val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true + return AddressEntry( + id = AddressEntryId(UUID.randomUUID().toString()), + address = address, + networkId = Network.RawID(rawId), + networkName = blockchain?.fullName ?: rawId, + memo = memo?.takeIf { hasExtrasSupport }, + signature = "", + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index 90f414fabf..cdcdb538ff 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -1,41 +1,91 @@ package com.tangem.features.addressbook.editcontact.model +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.interactor.SaveContactInteractor +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked import com.tangem.features.addressbook.common.AddressBookResultHolder import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.state.EditContactStateController -import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer -import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer -import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer -import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.* +import com.tangem.features.addressbook.editcontact.state.transformers.converter.ContactNameErrorConverter import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class EditContactModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val stateController: EditContactStateController, private val resultHolder: AddressBookResultHolder, + private val messageSender: UiMessageSender, + private val userWalletsListRepository: UserWalletsListRepository, + private val validateContactNameUseCase: ValidateContactNameUseCase, + private val saveContactInteractor: SaveContactInteractor, + val portfolioSelectorController: PortfolioSelectorController, + portfolioFetcherFactory: PortfolioFetcher.Factory, ) : Model() { private val params: DefaultEditContactComponent.Params = paramsContainer.require() + private val selectedWalletId = MutableStateFlow(null) + + /** The in-flight save coroutine β€” its [Job.isActive] drives both the re-entrancy guard and the button state. */ + private var saveJob: Job? = null + val state: StateFlow get() = stateController.uiState + val portfolioSelectorNavigation = SlotNavigation() + + val portfolioFetcher: PortfolioFetcher by lazy { + portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = false), + scope = modelScope, + ) + } + + val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { + override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() } + override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() } + } + init { updateInitialState() prefillPredefinedAddress() subscribeToConfirmedAddresses() + initSelectedWallet() + observeWalletSelection() + observeWalletBlock() + observeNameValidation() + observeSaveButton() } /** In WithContactCreation mode the contact opens with the already-known address attached. */ @@ -50,11 +100,157 @@ internal class EditContactModel @Inject constructor( onNameChange = ::onNameChange, onColorSelect = ::onColorSelect, onCloseClick = params.onBackClick, - onAddAddressClick = params.onAddAddressClick, + onAddAddressClick = ::onAddAddressClick, + onSaveClick = ::onSaveClick, ), ) } + private fun initSelectedWallet() { + // TODO: For an existing contact the contact's own wallet should be used here once existing-contact + // loading is implemented. For now both new and existing contacts default to the selected wallet. + userWalletsListRepository.selectedUserWallet + .filterNotNull() + .onEach { wallet -> + if (selectedWalletId.value == null) selectedWalletId.value = wallet.walletId + } + .launchIn(modelScope) + } + + /** Maps the account picked in the selector back to its wallet (wallet-only mode picks the main account). */ + private fun observeWalletSelection() { + portfolioSelectorController.selectedAccountWithData(portfolioFetcher) + .mapNotNull { it?.first?.walletId } + .onEach { walletId -> + selectedWalletId.value = walletId + portfolioSelectorNavigation.dismiss() + } + .launchIn(modelScope) + } + + private fun observeWalletBlock() { + combine( + selectedWalletId, + userWalletsListRepository.userWallets, + ) { walletId, wallets -> + UpdateWalletBlockTransformer( + walletName = wallets?.firstOrNull { it.walletId == walletId }?.name.orEmpty(), + isChangeable = isWalletChangeable(wallets), + onClick = ::onWalletBlockClick, + ) + } + .onEach(stateController::update) + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun isWalletChangeable(wallets: List?): Boolean { + val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked } + return params.contactId == null && unlockedWalletsCount > 1 + } + + private fun onWalletBlockClick() { + if (isWalletChangeable(userWalletsListRepository.userWallets.value)) { + portfolioSelectorNavigation.activate(Unit) + } + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun observeNameValidation() { + combine( + stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS), + selectedWalletId.filterNotNull(), + ) { name, walletId -> name to walletId } + .mapLatest { (name, walletId) -> validateName(name, walletId) } + .onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) } + .launchIn(modelScope) + } + + private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? { + if (name.isBlank()) return null + val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null + // A blank name must not surface an inline error; the Empty case is treated as "no error". + if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null + return ContactNameErrorConverter().convert(error) + } + + private fun observeSaveButton() { + stateController.uiState + .map { state -> + SaveButtonInputs( + name = state.name, + hasNameError = state.nameError != null, + hasAddresses = state.addresses.isNotEmpty(), + ) + } + .distinctUntilChanged() + .onEach { refreshSaveButton() } + .launchIn(modelScope) + } + + /** Recomputes the button from the current inputs and whether a save is running ([saveJob] is active). */ + private fun refreshSaveButton() { + val ui = stateController.uiState.value + val isSaving = saveJob?.isActive == true + val isEnabled = ui.name.isNotBlank() && ui.nameError == null && ui.addresses.isNotEmpty() && !isSaving + stateController.update(UpdateSaveButtonTransformer(isEnabled = isEnabled, isLoading = isSaving)) + } + + private fun onSaveClick() { + if (saveJob?.isActive == true) return + val userWallet = userWalletsListRepository.userWallets.value + ?.firstOrNull { it.walletId == selectedWalletId.value } + ?: return + val ui = stateController.uiState.value + val addressEntries = ContactAddressEntriesConverter().convert(ui.addresses) + + saveJob = modelScope.launch { + try { + // TODO: existing-contact update needs the loaded Contact; existing-contact loading is not implemented. + val result = saveContactInteractor.createContact( + userWallet = userWallet, + name = ui.name, + iconColor = ui.colors.selected.name, + addressEntries = addressEntries, + ) + result.fold( + ifLeft = ::handleSaveError, + ifRight = { params.onBackClick() }, + ) + } finally { + refreshSaveButton() + } + } + refreshSaveButton() + } + + private fun handleSaveError(error: SaveContactError) { + when (error) { + is SaveContactError.Name -> stateController.update( + UpdateNameErrorTransformer(ContactNameErrorConverter().convert(error.error)), + ) + else -> messageSender.send( + DialogMessage( + title = resourceReference(R.string.common_something_went_wrong), + message = resourceReference(R.string.address_book_creating_error), + ), + ) + } + } + + private fun onAddAddressClick() { + if (stateController.uiState.value.addresses.size >= MAX_ADDRESSES) { + messageSender.send( + DialogMessage( + title = resourceReference(R.string.address_book_max_networks_alert_title), + message = resourceReference(R.string.address_book_max_networks_alert_description), + ), + ) + } else { + params.onAddAddressClick() + } + } + private fun subscribeToConfirmedAddresses() { resultHolder.confirmedAddress .filterNotNull() @@ -74,6 +270,17 @@ internal class EditContactModel @Inject constructor( } private fun addAddress(address: ValidatedAddress) { - stateController.update(AddValidatedAddressTransformer(address = address)) + stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES)) + } + + private data class SaveButtonInputs( + val name: String, + val hasNameError: Boolean, + val hasAddresses: Boolean, + ) + + private companion object { + const val MAX_ADDRESSES = 20 + const val NAME_DEBOUNCE_MS = 300L } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt index bd75ec37da..aad3317653 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt @@ -3,6 +3,8 @@ package com.tangem.features.addressbook.editcontact.state import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -32,6 +34,7 @@ internal class EditContactStateController @Inject constructor() { title = TextReference.EMPTY, name = "", namePlaceholder = resourceReference(R.string.address_book_new_contact), + nameError = null, portfolioIcon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, color = selectedColor, @@ -42,6 +45,18 @@ internal class EditContactStateController @Inject constructor() { onColorSelect = {}, ), addresses = persistentListOf(), + walletBlock = EditContactUM.WalletBlockUM( + walletName = "", + isChangeable = false, + onClick = {}, + ), + isAddAddressEnabled = true, + saveButton = TangemButtonUM( + text = TextReference.Res(R.string.common_save), + type = TangemButtonType.Primary, + isEnabled = false, + onClick = {}, + ), onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt index 232b07210f..9e2fd722b0 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt @@ -7,13 +7,16 @@ import kotlinx.collections.immutable.toImmutableList internal class AddValidatedAddressTransformer( private val address: ValidatedAddress, + private val maxAddresses: Int, ) : Transformer { override fun transform(prevState: EditContactUM): EditContactUM { // Skip duplicates: an address is identified by its string value (it already carries all its networks). if (prevState.addresses.any { it.address == address.address }) return prevState + val addresses = (prevState.addresses + address).toImmutableList() return prevState.copy( - addresses = (prevState.addresses + address).toImmutableList(), + addresses = addresses, + isAddAddressEnabled = addresses.size < maxAddresses, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt index 26d8bdd3c8..1ad80cf557 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt @@ -16,6 +16,7 @@ internal class UpdateEditContactInitialStateTransformer( private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, private val onCloseClick: () -> Unit, private val onAddAddressClick: () -> Unit, + private val onSaveClick: () -> Unit, ) : Transformer { override fun transform(prevState: EditContactUM): EditContactUM { @@ -27,6 +28,7 @@ internal class UpdateEditContactInitialStateTransformer( return prevState.copy( title = resourceReference(titleResId), colors = prevState.colors.copy(onColorSelect = onColorSelect), + saveButton = prevState.saveButton.copy(onClick = onSaveClick), onNameChange = onNameChange, onCloseClick = onCloseClick, onAddAddressClick = onAddAddressClick, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateNameErrorTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateNameErrorTransformer.kt new file mode 100644 index 0000000000..06712e7638 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateNameErrorTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateNameErrorTransformer(private val error: TextReference?) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy(nameError = error) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateSaveButtonTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateSaveButtonTransformer.kt new file mode 100644 index 0000000000..90e13775ae --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateSaveButtonTransformer.kt @@ -0,0 +1,16 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSaveButtonTransformer( + private val isEnabled: Boolean, + private val isLoading: Boolean, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy( + saveButton = prevState.saveButton.copy(isEnabled = isEnabled, isLoading = isLoading), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateWalletBlockTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateWalletBlockTransformer.kt new file mode 100644 index 0000000000..9468ef1755 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateWalletBlockTransformer.kt @@ -0,0 +1,21 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateWalletBlockTransformer( + private val walletName: String, + private val isChangeable: Boolean, + private val onClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy( + walletBlock = EditContactUM.WalletBlockUM( + walletName = walletName, + isChangeable = isChangeable, + onClick = onClick, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/converter/ContactNameErrorConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/converter/ContactNameErrorConverter.kt new file mode 100644 index 0000000000..f54d506714 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/converter/ContactNameErrorConverter.kt @@ -0,0 +1,20 @@ +package com.tangem.features.addressbook.editcontact.state.transformers.converter + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.utils.converter.Converter + +internal class ContactNameErrorConverter : Converter { + + override fun convert(value: ContactNameValidationError): TextReference = when (value) { + ContactNameValidationError.Duplicate -> resourceReference(R.string.address_book_name_taken_error) + is ContactNameValidationError.Format -> when (value.error) { + ContactName.Error.ExceedsMaxLength -> resourceReference(R.string.address_book_name_max_chars_error) + ContactName.Error.InvalidCharacters -> resourceReference(R.string.address_book_name_invalid_chars_error) + ContactName.Error.Empty -> resourceReference(R.string.address_book_name_empty_error) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt index 4334ba0f66..5a1ca89b08 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -5,11 +5,16 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -23,6 +28,8 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.fields.AutoSizeTextField +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar @@ -66,26 +73,56 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif }, ) - Column( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .weight(1f), ) { - ContactSummary(state = state) - ContactColor(colors = state.colors) - BlockCard( - shape = RoundedCornerShape(24.dp), - colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), + val minContentHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), ) { - ContactAddresses(addresses = state.addresses) - AddAddressRow(onClick = state.onAddAddressClick) + Column( + modifier = Modifier + .heightIn(min = minContentHeight) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ContactSummary(state = state) + ContactColor(colors = state.colors) + BlockCard( + shape = RoundedCornerShape(24.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), + ) { + ContactAddresses(addresses = state.addresses) + AddAddressRow(isEnabled = state.isAddAddressEnabled, onClick = state.onAddAddressClick) + } + WalletBlock(walletBlock = state.walletBlock) + } + Spacer(modifier = Modifier.weight(1f)) + SaveButton(saveButton = state.saveButton) + } } } } } +@Composable +private fun SaveButton(saveButton: TangemButtonUM) { + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + text = saveButton.text, + onClick = saveButton.onClick, + isEnabled = saveButton.isEnabled, + isLoading = saveButton.isLoading, + size = TangemButton.Size.X12, + ) +} + @Composable private fun ContactAddresses(addresses: ImmutableList) { addresses.fastForEach { entry -> @@ -126,7 +163,7 @@ private fun AddressRow(entry: ValidatedAddress) { } @Composable -private fun AddAddressRow(onClick: () -> Unit) { +private fun AddAddressRow(isEnabled: Boolean, onClick: () -> Unit) { TangemRow( verticalAlignment = TangemRowVerticalAlignment.Center, onClick = onClick, @@ -134,32 +171,113 @@ private fun AddAddressRow(onClick: () -> Unit) { TangemIcon( tangemIconUM = TangemIconUM.Icon( imageVector = Icons.ic_sign_plus_20, - tintReference = { TangemTheme.colors3.icon.brand }, + tintReference = { + if (isEnabled) { + TangemTheme.colors3.icon.brand + } else { + TangemTheme.colors3.icon.tertiary + } + }, ), modifier = Modifier .size(40.dp) .background( - color = TangemTheme.colors3.bg.status.infoSubtle, + color = if (isEnabled) { + TangemTheme.colors3.bg.status.infoSubtle + } else { + TangemTheme.colors3.bg.opaque.secondary + }, shape = RoundedCornerShape(10.dp), ) .padding(8.dp), ) }, titleSlot = { - TangemRowText( - text = TextReference.Res(R.string.address_book_add_address), - role = TangemRowTextRole.Title, - ) + if (isEnabled) { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address), + role = TangemRowTextRole.Title, + ) + } else { + Text( + text = stringResourceSafe(R.string.address_book_add_address), + color = TangemTheme.colors3.text.tertiary, + style = TangemTheme.typography3.body.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } }, subtitleSlot = { - TangemRowText( - text = TextReference.Res(R.string.address_book_add_address_description), - role = TangemRowTextRole.Subtitle, - ) + if (isEnabled) { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address_description), + role = TangemRowTextRole.Subtitle, + ) + } else { + Text( + text = stringResourceSafe(R.string.address_book_add_address_description), + color = TangemTheme.colors3.text.tertiary, + style = TangemTheme.typography3.caption.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } }, ) } +@Composable +private fun WalletBlock(walletBlock: EditContactUM.WalletBlockUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + ) { + TangemRow( + onClick = if (walletBlock.isChangeable) walletBlock.onClick else null, + verticalAlignment = TangemRowVerticalAlignment.Center, + titleSlot = { + TangemRowText( + text = stringResourceSafe(R.string.address_book_save_to_wallet_title), + role = TangemRowTextRole.Title, + ) + }, + endSlot = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = walletBlock.walletName, + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.caption.medium.fontSize, + maxFontSize = TangemTheme.typography3.body.medium.fontSize, + ), + ) + if (walletBlock.isChangeable) { + WalletChevronIcon() + } + } + }, + ) + } +} + +@Composable +private fun WalletChevronIcon() { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(20.dp), + tint = TangemTheme.colors3.icon.secondary, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24), + contentDescription = null, + ) +} + @Composable private fun ContactSummary(state: EditContactUM) { val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() } @@ -199,6 +317,17 @@ private fun ContactSummary(state: EditContactUM) { color = TangemTheme.colors3.text.primary, placeholderColor = TangemTheme.colors3.text.tertiary, ) + + if (state.nameError != null) { + Text( + modifier = Modifier.padding(top = 4.dp), + textAlign = TextAlign.Center, + text = state.nameError.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.status.error, + ) + } + SpacerH(8.dp) } } @@ -264,6 +393,7 @@ private fun Preview_EditContactContent() { title = stringReference("New contact"), name = "", namePlaceholder = stringReference("New contact"), + nameError = null, portfolioIcon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, color = colors.first(), @@ -279,6 +409,18 @@ private fun Preview_EditContactContent() { networkIds = persistentListOf("ethereum", "bsc", "polygon"), ), ), + walletBlock = EditContactUM.WalletBlockUM( + walletName = "Main Wallet", + isChangeable = true, + onClick = {}, + ), + isAddAddressEnabled = true, + saveButton = TangemButtonUM( + text = TextReference.Res(R.string.common_save), + type = TangemButtonType.Primary, + isEnabled = true, + onClick = {}, + ), onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt index 55793600ad..f8385617d1 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt @@ -2,6 +2,7 @@ package com.tangem.features.addressbook.editcontact.ui.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList @@ -11,9 +12,13 @@ internal data class EditContactUM( val title: TextReference, val name: String, val namePlaceholder: TextReference, + val nameError: TextReference?, val portfolioIcon: AccountIconUM.CryptoPortfolio, val colors: Colors, val addresses: ImmutableList, + val walletBlock: WalletBlockUM, + val isAddAddressEnabled: Boolean, + val saveButton: TangemButtonUM, val onNameChange: (String) -> Unit, val onCloseClick: () -> Unit, val onAddAddressClick: () -> Unit, @@ -24,4 +29,10 @@ internal data class EditContactUM( val list: ImmutableList, val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, ) + + data class WalletBlockUM( + val walletName: String, + val isChangeable: Boolean, + val onClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt index 9a17943008..39e223b145 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt @@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.tangem.core.ui.R +import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton @@ -31,7 +32,6 @@ import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewPar import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -import com.tangem.features.addressbook.list.ui.state.ContactUM import com.tangem.features.addressbook.list.ui.state.ContentMode import kotlinx.collections.immutable.ImmutableList @@ -91,18 +91,19 @@ internal fun AddressBookListScreen( NothingFoundContent() } else { LazyColumn( - modifier = Modifier - .imePadding() - .padding(top = 16.dp) - .padding(horizontal = 16.dp) - .background( - color = TangemTheme.colors3.bg.secondary, - shape = RoundedCornerShape(24.dp), - ), + modifier = Modifier.imePadding(), contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight), ) { - items(items = state.contacts, key = ContactUM::id) { contact -> - ContactRow(contact = contact) + itemsIndexed(items = state.contacts, key = { _, contact -> contact.id }) { index, contact -> + ContactRow( + contact = contact, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.contacts.lastIndex, + radius = 24.dp, + backgroundColor = TangemTheme.colors3.bg.secondary, + ), + ) } } } diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index cdf098ec82..40b59525d2 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -1,36 +1,81 @@ package com.tangem.features.addressbook.editcontact.model +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.interactor.SaveContactInteractor +import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.addressbook.common.AddressBookResultHolder import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.state.EditContactStateController import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @OptIn(ExperimentalCoroutinesApi::class) internal class EditContactModelTest { private val resultHolder = AddressBookResultHolder() + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) + private val validateContactNameUseCase: ValidateContactNameUseCase = mockk() + private val saveContactInteractor: SaveContactInteractor = mockk() + private val portfolioSelectorController: PortfolioSelectorController = mockk() + private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true) + private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk() + + // Drives the wallet picked in the reused portfolio selector; `first` of the pair is the chosen wallet. + private val selectedWalletData = + MutableSharedFlow?>(extraBufferCapacity = 1) private var model: EditContactModel? = null + @BeforeEach + fun setUp() { + // Default: no wallets loaded, name always valid. Individual tests override as needed. + setupWallets(wallets = emptyList(), selected = null) + coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right() + every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher + every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData + } + @AfterEach fun tearDown() { // Cancels modelScope, stopping the confirmed-addresses collector. @@ -44,12 +89,14 @@ internal class EditContactModelTest { val expectedSelectedColor = expectedColors.first() val model = createModel(testScope = this) + advanceUntilIdle() val state = model.state.value val expected = EditContactUM( title = resourceReference(R.string.address_book_new_contact), name = "", namePlaceholder = resourceReference(R.string.address_book_new_contact), + nameError = null, portfolioIcon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, color = expectedSelectedColor, @@ -60,6 +107,13 @@ internal class EditContactModelTest { onColorSelect = state.colors.onColorSelect, ), addresses = persistentListOf(), + walletBlock = EditContactUM.WalletBlockUM( + walletName = "", + isChangeable = false, + onClick = state.walletBlock.onClick, + ), + isAddAddressEnabled = true, + saveButton = state.saveButton, onNameChange = state.onNameChange, onCloseClick = state.onCloseClick, onAddAddressClick = state.onAddAddressClick, @@ -149,14 +203,249 @@ internal class EditContactModelTest { assertThat(model.state.value.addresses).containsExactly(predefined) } + @Test + fun `GIVEN below address limit WHEN onAddAddressClick THEN click propagated AND no dialog`() = runTest { + // Arrange + var addClicked = false + val model = createModel(testScope = this, params = createParams(onAddAddressClick = { addClicked = true })) + advanceUntilIdle() + + // Act + model.state.value.onAddAddressClick() + + // Assert + assertThat(addClicked).isTrue() + verify(exactly = 0) { messageSender.send(any()) } + } + + @Test + fun `GIVEN max addresses reached WHEN onAddAddressClick THEN limit dialog shown AND click not propagated`() = + runTest { + // Arrange + var addClicked = false + val model = createModel(testScope = this, params = createParams(onAddAddressClick = { addClicked = true })) + advanceUntilIdle() + repeat(MAX_ADDRESSES) { index -> + resultHolder.setConfirmedAddress( + ValidatedAddress(address = "0x$index", networkIds = persistentListOf("ethereum")), + ) + advanceUntilIdle() + } + + // Act + model.state.value.onAddAddressClick() + + // Assert + assertThat(model.state.value.addresses).hasSize(MAX_ADDRESSES) + assertThat(model.state.value.isAddAddressEnabled).isFalse() + assertThat(addClicked).isFalse() + verify { messageSender.send(any()) } + } + + @Test + fun `GIVEN new contact AND multiple unlocked wallets WHEN created THEN wallet block changeable`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + val walletB = createWallet(id = "bb", name = "Wallet B") + setupWallets(wallets = listOf(walletA, walletB), selected = walletA) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val block = model.state.value.walletBlock + assertThat(block.isChangeable).isTrue() + assertThat(block.walletName).isEqualTo("Wallet A") + } + + @Test + fun `GIVEN new contact AND single unlocked wallet WHEN created THEN wallet block not changeable`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.walletBlock.isChangeable).isFalse() + } + + @Test + fun `GIVEN duplicate name in selected wallet WHEN name entered THEN name error shown`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + coEvery { + validateContactNameUseCase(any(), any()) + } returns ContactNameValidationError.Duplicate.left() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onNameChange("Satoshi") + advanceUntilIdle() + + // Assert + assertThat(model.state.value.nameError) + .isEqualTo(resourceReference(R.string.address_book_name_taken_error)) + } + + @Test + fun `GIVEN unique name in selected wallet WHEN name entered THEN no name error`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onNameChange("Satoshi") + advanceUntilIdle() + + // Assert + assertThat(model.state.value.nameError).isNull() + } + + @Test + fun `GIVEN multiple wallets WHEN wallet picked in selector THEN block reflects chosen wallet AND name re-validated`() = + runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + val walletB = createWallet(id = "bb", name = "Wallet B") + setupWallets(wallets = listOf(walletA, walletB), selected = walletA) + coEvery { + validateContactNameUseCase(walletB.walletId, "Satoshi") + } returns ContactNameValidationError.Duplicate.left() + val model = createModel(testScope = this) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + advanceUntilIdle() + + // Act β€” the reused portfolio selector reports wallet B (wallet-only mode maps to its main account). + selectedWalletData.tryEmit(walletB to mockk()) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.walletBlock.walletName).isEqualTo("Wallet B") + assertThat(model.state.value.nameError) + .isEqualTo(resourceReference(R.string.address_book_name_taken_error)) + } + + @Test + fun `GIVEN valid name address and wallet WHEN observed THEN save button enabled`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onNameChange("Satoshi") + resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.saveButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN name but no address WHEN observed THEN save button disabled`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onNameChange("Satoshi") + advanceUntilIdle() + + // Assert + assertThat(model.state.value.saveButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN name error WHEN observed THEN save button disabled`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + coEvery { validateContactNameUseCase(any(), any()) } returns ContactNameValidationError.Duplicate.left() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onNameChange("Satoshi") + resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.saveButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid contact WHEN save clicked THEN createContact called AND navigates back`() = runTest { + // Arrange + var navigatedBack = false + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns mockk().right() + val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true })) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert + coVerify { + saveContactInteractor.createContact( + userWallet = walletA, + name = "Satoshi", + iconColor = any(), + addressEntries = any(), + ) + } + assertThat(navigatedBack).isTrue() + } + + @Test + fun `GIVEN save returns name error WHEN save clicked THEN inline name error shown`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns + SaveContactError.Name(ContactNameValidationError.Duplicate).left() + val model = createModel(testScope = this) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert + assertThat(model.state.value.nameError) + .isEqualTo(resourceReference(R.string.address_book_name_taken_error)) + } + private fun createParams( contactId: ContactId? = null, predefinedAddress: ValidatedAddress? = null, + onAddAddressClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, ): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params( contactId = contactId, predefinedAddress = predefinedAddress, - onBackClick = {}, - onAddAddressClick = {}, + onBackClick = onBackClick, + onAddAddressClick = onAddAddressClick, ) private fun createModel( @@ -169,9 +458,23 @@ internal class EditContactModelTest { dispatchers = testScope.createTestingCoroutineDispatcherProvider(), stateController = EditContactStateController(), resultHolder = resultHolder, + messageSender = messageSender, + userWalletsListRepository = userWalletsListRepository, + validateContactNameUseCase = validateContactNameUseCase, + saveContactInteractor = saveContactInteractor, + portfolioSelectorController = portfolioSelectorController, + portfolioFetcherFactory = portfolioFetcherFactory, ).also { model = it } } + private fun setupWallets(wallets: List, selected: UserWallet?) { + every { userWalletsListRepository.userWallets } returns MutableStateFlow(wallets) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(selected) + } + + private fun createWallet(id: String, name: String): UserWallet = + MockUserWalletFactory.create().copy(walletId = UserWalletId(id), name = name) + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { val testDispatcher = StandardTestDispatcher(testScheduler) return TestingCoroutineDispatcherProvider( @@ -182,4 +485,8 @@ internal class EditContactModelTest { single = testDispatcher, ) } + + private companion object { + const val MAX_ADDRESSES = 20 + } } \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt index 8d658a65ac..411f9e94a8 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt @@ -32,6 +32,15 @@ interface PortfolioSelectorComponent : ComposableBottomSheetComponent, Composabl val portfolioFetcher: PortfolioFetcher, val controller: PortfolioSelectorController, val bsCallback: BottomSheetCallback? = null, + val settings: Settings = Settings(), + ) + + /** + * @param isWalletSelectionOnly when `true`, the selector always shows a flat wallet list and ignores the global + * accounts mode (no account grouping). + */ + data class Settings( + val isWalletSelectionOnly: Boolean = false, ) interface BottomSheetCallback { diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt index bed4000af3..eaf50cc34e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt @@ -59,14 +59,15 @@ internal class PortfolioSelectorModel @Inject constructor( flow4 = selectorController.isEnabled, flow5 = selectedAccountState, transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount -> + val isAccountsModeEffective = isAccountsMode && !params.settings.isWalletSelectionOnly val uiList = buildUiList( - isAccountsMode = isAccountsMode, + isAccountsMode = isAccountsModeEffective, portfolioData = portfolioData, artworks = artworks, isEnabled = isEnabled, selectedAccount = selectedAccount, ) - val title = when (isAccountsMode) { + val title = when (isAccountsModeEffective) { true -> resourceReference(R.string.common_choose_account) false -> resourceReference(R.string.common_choose_wallet) }