From 889f47acdbe0d45f5af8c000cfff31e4f3beb4cb Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 12:58:53 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../addaddress/DefaultAddAddressComponent.kt | 9 +- .../addaddress/model/AddAddressModel.kt | 50 +- .../UpdateAddressValidationTransformer.kt | 17 +- .../common/AddressBookChildFactory.kt | 9 +- .../common/AddressBookClickIntents.kt | 6 +- .../common/AddressBookResultHolder.kt | 17 +- .../common/DefaultAddressBookComponent.kt | 23 +- .../addressbook/di/AddressBookModelModule.kt | 6 + .../DefaultEditContactComponent.kt | 29 +- .../editcontact/model/EditContactModel.kt | 465 +++++++++++++----- .../state/EditContactStateController.kt | 4 +- ...pdateEditContactInitialStateTransformer.kt | 6 + .../UpdateSaveButtonTransformer.kt | 10 +- .../editcontact/ui/EditContactContent.kt | 104 ++-- .../editcontact/ui/state/EditContactUM.kt | 2 + .../list/ui/AddressBookListScreen.kt | 11 +- .../addressbook/route/AddressBookRoute.kt | 20 +- .../DefaultSelectNetworksComponent.kt | 2 +- .../model/SelectNetworksModel.kt | 5 +- .../addaddress/model/AddAddressModelTest.kt | 126 ++++- .../editcontact/model/EditContactModelTest.kt | 376 +++++++++++++- .../model/SelectNetworksModelTest.kt | 20 +- 22 files changed, 1058 insertions(+), 259 deletions(-) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt index 43f75ec912..636a9473c7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt @@ -30,8 +30,13 @@ internal class DefaultAddAddressComponent( } data class Params( + val walletId: String?, + val excludeContactId: String?, + val prefillAddress: String?, + val prefillNetworkIds: List, + val prefillMemo: String?, val onBackClick: () -> Unit, - val onSelectNetworksClick: (address: String, selectedNetworkIds: List) -> Unit, - val onConfirm: (ValidatedAddress) -> Unit, + val onSelectNetworksClick: (matchedNetworkIds: List, selectedNetworkIds: List) -> Unit, + val onConfirm: (address: ValidatedAddress, replaces: String?) -> Unit, ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index 36a6d06870..18ae9ce50d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -10,6 +10,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent @@ -42,6 +45,7 @@ internal class AddAddressModel @Inject constructor( private val clipboardManager: ClipboardManager, private val stateController: AddAddressStateController, private val selectNetworksResultHolder: SelectNetworksResultHolder, + private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase, private val router: Router, ) : Model() { @@ -83,15 +87,42 @@ internal class AddAddressModel @Inject constructor( ChosenNetworks(address = "", matched = emptyList(), displayed = emptyList(), selected = emptyList()), ) + /** + * Name of the contact that already holds one of the selected `network + address` pairs in the target wallet, or + * `null` when the pair is free. + */ + private val duplicateName: StateFlow = chosenNetworks + .mapLatest { networks -> + val walletId = params.walletId ?: return@mapLatest null + if (networks.selected.isEmpty()) return@mapLatest null + networks.selected.firstNotNullOfOrNull { blockchain -> + checkAddressDuplicateUseCase( + userWalletId = UserWalletId(walletId), + networkId = blockchain.toNetworkId(), + address = networks.address, + excludeContactId = params.excludeContactId?.let(::ContactId), + ) + } + } + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, null) + init { // Drop any selection left over from a previous AddAddress session before subscribing to it. selectNetworksResultHolder.clear() updateInitialState() subscribeToValidation() subscribeToMemoValidation() - resetSelectionOnAddressChange() subscribeToSelectedNetworks() subscribeToQrScanResult() + prefillData() + } + + private fun prefillData() { + val prefillAddress = params.prefillAddress ?: return + onAddressChange(prefillAddress) + selectedNetworkIds.value = params.prefillNetworkIds.toSet().ifEmpty { null } + params.prefillMemo?.let(::onMemoChange) } private fun updateInitialState() { @@ -114,6 +145,7 @@ internal class AddAddressModel @Inject constructor( private fun onAddressChange(value: String) { stateController.update(UpdateAddressInputTransformer(value = value)) + selectedNetworkIds.value = null } private fun onMemoChange(value: String) { @@ -121,13 +153,14 @@ internal class AddAddressModel @Inject constructor( } private fun subscribeToValidation() { - combine(chosenNetworks, isMemoInvalid) { networks, memoInvalid -> + combine(chosenNetworks, isMemoInvalid, duplicateName) { networks, memoInvalid, duplicate -> UpdateAddressValidationTransformer( address = networks.address, matchedBlockchains = networks.matched, displayedBlockchains = networks.displayed, selectedBlockchains = networks.selected, isMemoInvalid = memoInvalid, + duplicateName = duplicate, ) } .onEach(stateController::update) @@ -146,14 +179,6 @@ internal class AddAddressModel @Inject constructor( .launchIn(modelScope) } - private fun resetSelectionOnAddressChange() { - validation - .map { it.address } - .distinctUntilChanged() - .onEach { selectedNetworkIds.value = null } - .launchIn(modelScope) - } - private fun subscribeToSelectedNetworks() { selectNetworksResultHolder.selectedNetworkIds .filterNotNull() @@ -195,7 +220,7 @@ internal class AddAddressModel @Inject constructor( private fun onNetworkClick() { params.onSelectNetworksClick( - stateController.uiState.value.addressField.value, + chosenNetworks.value.matched.map { it.toNetworkId() }, selectedNetworkIds.value?.toList().orEmpty(), ) } @@ -203,6 +228,7 @@ internal class AddAddressModel @Inject constructor( private fun validateAndConfirm() { val networks = chosenNetworks.value if (networks.selected.isEmpty()) return + if (duplicateName.value != null) return val memoField = stateController.uiState.value.memoField val memo = memoField.value.trim().takeIf { memoField.isVisible && it.isNotEmpty() } @@ -212,6 +238,8 @@ internal class AddAddressModel @Inject constructor( networkIds = networks.selected.map { it.toNetworkId() }.toImmutableList(), memo = memo, ), + // In the edit-address flow this confirmation supersedes the entry the screen was opened for. + params.prefillAddress, ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt index 8421b1efc7..160e83b3b2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.network.Network import com.tangem.features.addressbook.addaddress.state.transformers.converter.ChosenNetworkConverter import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM @@ -30,11 +31,14 @@ internal class UpdateAddressValidationTransformer( private val displayedBlockchains: List, private val selectedBlockchains: List, private val isMemoInvalid: Boolean, + private val duplicateName: String?, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { val hasMatch = matchedBlockchains.isNotEmpty() - val isError = address.isNotBlank() && !hasMatch + val isInvalidAddress = address.isNotBlank() && !hasMatch + val isDuplicate = duplicateName != null + val isError = isInvalidAddress || isDuplicate val chosenNetworkState = if (hasMatch) { ChosenNetworkStateUM.Result( @@ -46,16 +50,17 @@ internal class UpdateAddressValidationTransformer( ChosenNetworkStateUM.Hidden } - val label = if (isError) { - resourceReference(R.string.address_book_invalid_address_error) - } else { - resourceReference(R.string.common_address) + val label = when { + isDuplicate -> resourceReference(R.string.address_book_address_taken_error, wrappedList(duplicateName)) + isInvalidAddress -> resourceReference(R.string.address_book_invalid_address_error) + else -> resourceReference(R.string.common_address) } + val isConfirmEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid && !isDuplicate return prevState.copy( addressField = prevState.addressField.copy(isError = isError, label = label), chosenNetworkStateUM = chosenNetworkState, memoField = resolveMemoField(prevState.memoField), - buttonUM = prevState.buttonUM.copy(isEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid), + buttonUM = prevState.buttonUM.copy(isEnabled = isConfirmEnabled), ) } 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 3e989cde35..ff434dd169 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 @@ -47,9 +47,14 @@ internal class AddressBookChildFactory @Inject constructor( ), portfolioSelectorComponentFactory = portfolioSelectorComponentFactory, ) - AddressBookRoute.AddAddress -> DefaultAddAddressComponent( + is AddressBookRoute.AddAddress -> DefaultAddAddressComponent( appComponentContext = context, params = DefaultAddAddressComponent.Params( + walletId = route.walletId, + excludeContactId = route.excludeContactId, + prefillAddress = route.prefillAddress, + prefillNetworkIds = route.prefillNetworkIds, + prefillMemo = route.prefillMemo, onBackClick = clickIntents::onAddAddressBack, onSelectNetworksClick = clickIntents::onSelectNetworksClick, onConfirm = clickIntents::onAddressConfirmed, @@ -58,7 +63,7 @@ internal class AddressBookChildFactory @Inject constructor( is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent( appComponentContext = context, params = DefaultSelectNetworksComponent.Params( - address = route.address, + matchedNetworkIds = route.matchedNetworkIds, selectedNetworkIds = route.selectedNetworkIds, onBackClick = clickIntents::onSelectNetworksBack, onDone = clickIntents::onNetworksSelected, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt index 315ba66a4a..472c2d2aea 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt @@ -18,13 +18,13 @@ internal interface AddressBookClickIntents { fun onEditContactBack() - fun onAddAddressClick() + fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) fun onAddAddressBack() - fun onAddressConfirmed(address: ValidatedAddress) + fun onAddressConfirmed(address: ValidatedAddress, replaces: String?) - fun onSelectNetworksClick(address: String, selectedNetworkIds: List) + fun onSelectNetworksClick(matchedNetworkIds: List, selectedNetworkIds: List) fun onSelectNetworksBack() diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt index 1ba1423a22..2ec8c28bd8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt @@ -7,7 +7,7 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen. + * Carries an address confirmed on the AddAddress screen over to the EditContact screen. * * The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over * instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer @@ -16,14 +16,19 @@ import javax.inject.Singleton @Singleton internal class AddressBookResultHolder @Inject constructor() { - val confirmedAddress: StateFlow - field = MutableStateFlow(null) + val confirmedAddress: StateFlow + field = MutableStateFlow(null) - fun setConfirmedAddress(address: ValidatedAddress) { - confirmedAddress.value = address + fun setConfirmedAddress(confirmed: ConfirmedAddress) { + confirmedAddress.value = confirmed } fun clear() { confirmedAddress.value = null } -} \ No newline at end of file +} + +internal data class ConfirmedAddress( + val address: ValidatedAddress, + val replaces: String?, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt index 8dc6aa6ecc..248a4fc71d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -57,22 +57,33 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( navigation.pop() } - override fun onAddAddressClick() { - navigation.pushNew(AddressBookRoute.AddAddress) + override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) { + navigation.pushNew( + AddressBookRoute.AddAddress( + walletId = walletId, + excludeContactId = excludeContactId, + prefillAddress = prefill?.address, + prefillNetworkIds = prefill?.networkIds.orEmpty(), + prefillMemo = prefill?.memo, + ), + ) } override fun onAddAddressBack() { navigation.pop() } - override fun onAddressConfirmed(address: ValidatedAddress) { - resultHolder.setConfirmedAddress(address) + override fun onAddressConfirmed(address: ValidatedAddress, replaces: String?) { + resultHolder.setConfirmedAddress(ConfirmedAddress(address = address, replaces = replaces)) navigation.pop() } - override fun onSelectNetworksClick(address: String, selectedNetworkIds: List) { + override fun onSelectNetworksClick(matchedNetworkIds: List, selectedNetworkIds: List) { navigation.pushNew( - AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds), + AddressBookRoute.SelectNetworks( + matchedNetworkIds = matchedNetworkIds, + selectedNetworkIds = selectedNetworkIds, + ), ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index cd4e7b32e2..d653d8b483 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.addressbook.addaddress.model.AddAddressModel +import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel import com.tangem.features.addressbook.block.model.ContactsBlockModel import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.editcontact.model.EditContactModel @@ -41,4 +42,9 @@ internal interface AddressBookModelModule { @IntoMap @ClassKey(SelectNetworksModel::class) fun bindSelectNetworksModel(model: SelectNetworksModel): Model + + @Binds + @IntoMap + @ClassKey(AddressInfoModel::class) + fun bindAddressInfoModel(model: AddressInfoModel): Model } \ No newline at end of file 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 fd6c1c58c2..ab9fdd2ba7 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 @@ -8,12 +8,14 @@ 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.arkivanov.decompose.router.slot.dismiss 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.addressinfo.DefaultAddressInfoComponent 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 @@ -31,10 +33,19 @@ internal class DefaultEditContactComponent( private val portfolioSelectorSlot = childSlot( source = model.portfolioSelectorNavigation, serializer = Unit.serializer(), + key = "edit_contact_portfolio_selector_slot", handleBackButton = false, childFactory = { _, componentContext -> portfolioSelectorChild(componentContext) }, ) + private val addressInfoSlot = childSlot( + source = model.addressInfoNavigation, + serializer = String.serializer(), + key = "edit_contact_address_info_slot", + handleBackButton = false, + childFactory = { address, componentContext -> addressInfoChild(address, componentContext) }, + ) + private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent = portfolioSelectorComponentFactory.create( context = childByContext(componentContext), @@ -46,15 +57,22 @@ internal class DefaultEditContactComponent( ), ) + private fun addressInfoChild(address: String, componentContext: ComponentContext): ComposableBottomSheetComponent = + DefaultAddressInfoComponent( + appComponentContext = childByContext(componentContext), + params = model.createAddressInfoParams(address), + ) + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() val selectorSlot by portfolioSelectorSlot.subscribeAsState() + val infoSlot by addressInfoSlot.subscribeAsState() BackHandler { - if (selectorSlot.child != null) { - model.portfolioSelectorCallback.onBack() - } else { - state.onCloseClick() + when { + selectorSlot.child != null -> model.portfolioSelectorCallback.onBack() + infoSlot.child != null -> model.addressInfoNavigation.dismiss() + else -> state.onCloseClick() } } EditContactContent( @@ -62,12 +80,13 @@ internal class DefaultEditContactComponent( modifier = modifier, ) selectorSlot.child?.instance?.BottomSheet() + infoSlot.child?.instance?.BottomSheet() } data class Params( val contactId: ContactId?, val predefinedAddress: ValidatedAddress? = null, val onBackClick: () -> Unit, - val onAddAddressClick: () -> Unit, + val onAddAddressClick: (walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) -> Unit, ) } \ 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 b311624f37..aeb0a66b54 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 @@ -11,16 +11,22 @@ 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.core.ui.message.EventMessageAction +import com.tangem.core.ui.message.SnackbarMessage 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.ContactName +import com.tangem.domain.addressbook.usecase.DeleteContactUseCase +import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase 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.addressinfo.DefaultAddressInfoComponent import com.tangem.features.addressbook.common.AddressBookAnalyticsSender import com.tangem.features.addressbook.common.AddressBookResultHolder import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent @@ -40,7 +46,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "NamedArguments") @ModelScoped internal class EditContactModel @Inject constructor( paramsContainer: ParamsContainer, @@ -51,14 +57,26 @@ internal class EditContactModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val validateContactNameUseCase: ValidateContactNameUseCase, private val saveContactInteractor: SaveContactInteractor, + private val getContactByIdUseCase: GetContactByIdUseCase, + private val deleteContactUseCase: DeleteContactUseCase, private val analyticsSender: AddressBookAnalyticsSender, val portfolioSelectorController: PortfolioSelectorController, portfolioFetcherFactory: PortfolioFetcher.Factory, ) : Model() { + // region State + private val params: DefaultEditContactComponent.Params = paramsContainer.require() - private val selectedWalletId = MutableStateFlow(null) + /** The contact being edited (null for a new contact). Drives create-vs-update and the delete/discard rules. */ + private val loadedContact = MutableStateFlow(null) + + /** The editor's starting point: the loaded contact once available, otherwise the empty (or predefined) new contact. */ + private val newContactBaseline = EditSnapshot( + name = "", + colorName = CryptoPortfolioIcon.Color.entries.first().name, + addresses = listOfNotNull(params.predefinedAddress).map { it.address to it.networkIds.toSet() }, + ) /** The in-flight save coroutine — its [Job.isActive] drives both the re-entrancy guard and the button state. */ private var saveJob: Job? = null @@ -66,6 +84,7 @@ internal class EditContactModel @Inject constructor( val state: StateFlow get() = stateController.uiState val portfolioSelectorNavigation = SlotNavigation() + val addressInfoNavigation = SlotNavigation() val portfolioFetcher: PortfolioFetcher by lazy { portfolioFetcherFactory.create( @@ -74,20 +93,45 @@ internal class EditContactModel @Inject constructor( ) } + /** The wallet picked in the selector, if any — single source of the wallet the contact is being saved to. */ + private val pickedWallet: StateFlow = + portfolioSelectorController.selectedAccountWithData(portfolioFetcher) + .map { it?.first } + .stateIn(modelScope, SharingStarted.Eagerly, null) + + /** + * The wallet the contact is saved to. For an existing contact it is fixed to the contact's wallet; for a new + * contact it follows the selector pick and falls back to the app's currently selected wallet. + */ + private val selectedWallet: StateFlow = combine( + pickedWallet, + loadedContact, + userWalletsListRepository.selectedUserWallet, + userWalletsListRepository.userWallets, + ) { picked, contact, currentSelected, wallets -> + when { + contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId } + picked != null -> picked + else -> currentSelected + } + }.stateIn(modelScope, SharingStarted.Eagerly, null) + val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() } override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() } } + // endregion + init { updateInitialState() prefillPredefinedAddress() subscribeToConfirmedAddresses() - initSelectedWallet() - observeWalletSelection() + dismissSelectorOnPick() observeWalletBlock() observeNameValidation() observeSaveButton() + loadExistingContact() sendAddContactTappedEvent() } @@ -96,10 +140,7 @@ internal class EditContactModel @Inject constructor( analyticsSender.sendAddContactTapped(fromSendSuccess = params.predefinedAddress != null, scope = modelScope) } - /** In WithContactCreation mode the contact opens with the already-known address attached. */ - private fun prefillPredefinedAddress() { - params.predefinedAddress?.let(::addAddress) - } + // region Initialization private fun updateInitialState() { stateController.update( @@ -107,42 +148,74 @@ internal class EditContactModel @Inject constructor( isExistingContact = params.contactId != null, onNameChange = ::onNameChange, onColorSelect = ::onColorSelect, - onCloseClick = params.onBackClick, + onCloseClick = ::onCloseClick, onAddAddressClick = ::onAddAddressClick, + onAddressClick = ::onAddressClick, onSaveClick = ::onSaveClick, + onDeleteClick = if (params.contactId != null) ::onDeleteClick else null, ), ) } - 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 + /** In WithContactCreation mode the contact opens with the already-known address attached. */ + private fun prefillPredefinedAddress() { + params.predefinedAddress?.let(::addAddress) + } + + /** Loads an existing contact and prefills the editor. A new contact needs nothing — its baseline is empty. */ + private fun loadExistingContact() { + val contactId = params.contactId ?: return + modelScope.launch { + val contact = getContactByIdUseCase(contactId).first() + if (contact == null) { + params.onBackClick() + return@launch + } + loadedContact.value = contact + prefillFromContact(contact) + } + } + + private fun prefillFromContact(contact: Contact) { + stateController.update(UpdateContactNameTransformer(name = contact.name.value)) + CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor } + ?.let { stateController.update(SelectContactColorTransformer(color = it)) } + val addresses = ContactAddressEntriesConverter().toValidatedAddresses(contact.addressEntries) + stateController.update(SetValidatedAddressesTransformer(addresses = addresses, maxAddresses = MAX_ADDRESSES)) + } + + // endregion + + // region Subscriptions + + private fun subscribeToConfirmedAddresses() { + resultHolder.confirmedAddress .filterNotNull() - .onEach { wallet -> - if (selectedWalletId.value == null) selectedWalletId.value = wallet.walletId + .onEach { confirmed -> + // Edit-address: the result carries the entry it supersedes, so we swap it in place. + confirmed.replaces?.let { old -> + stateController.update( + RemoveValidatedAddressTransformer(address = old, maxAddresses = MAX_ADDRESSES), + ) + } + addAddress(confirmed.address) + resultHolder.clear() } .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() - } + /** Closes the wallet selector as soon as the user picks a wallet in it. */ + private fun dismissSelectorOnPick() { + pickedWallet + .filterNotNull() + .onEach { portfolioSelectorNavigation.dismiss() } .launchIn(modelScope) } private fun observeWalletBlock() { - combine( - selectedWalletId, - userWalletsListRepository.userWallets, - ) { walletId, wallets -> + combine(selectedWallet, userWalletsListRepository.userWallets) { wallet, wallets -> UpdateWalletBlockTransformer( - walletName = wallets?.firstOrNull { it.walletId == walletId }?.name.orEmpty(), + walletName = wallet?.name.orEmpty(), isChangeable = isWalletChangeable(wallets), onClick = ::onWalletBlockClick, ) @@ -152,9 +225,45 @@ internal class EditContactModel @Inject constructor( .launchIn(modelScope) } - private fun isWalletChangeable(wallets: List?): Boolean { - val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked } - return params.contactId == null && unlockedWalletsCount > 1 + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun observeNameValidation() { + combine( + stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS), + selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(), + ) { name, walletId -> name to walletId } + .mapLatest { (name, walletId) -> validateName(name, walletId) } + .onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) } + .launchIn(modelScope) + } + + private fun observeSaveButton() { + // Recompute on input changes and on wallet changes (the wallet type drives the button's Tangem-logo icon). + combine( + stateController.uiState + .map { state -> + SaveButtonInputs( + name = state.name, + hasNameError = state.nameError != null, + hasAddresses = state.addresses.isNotEmpty(), + ) + } + .distinctUntilChanged(), + selectedWallet.map { it is UserWallet.Cold }.distinctUntilChanged(), + ) { _, _ -> } + .onEach { refreshSaveButton() } + .launchIn(modelScope) + } + + // endregion + + // region Clicks + + private fun onNameChange(name: String) { + stateController.update(UpdateContactNameTransformer(name = name)) + } + + private fun onColorSelect(color: CryptoPortfolioIcon.Color) { + stateController.update(SelectContactColorTransformer(color = color)) } private fun onWalletBlockClick() { @@ -164,89 +273,168 @@ internal class EditContactModel @Inject constructor( } } - @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 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), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_ok), + onClick = onDismissRequest, + ) + }, + ), + ) + } else { + val walletId = selectedWallet.value?.walletId?.stringValue ?: return + analyticsSender.sendAddressScreenOpened() + params.onAddAddressClick(walletId, params.contactId?.value, null) + } } private fun onSaveClick() { if (saveJob?.isActive == true) return - val userWallet = userWalletsListRepository.userWallets.value - ?.firstOrNull { it.walletId == selectedWalletId.value } - ?: return + val userWallet = selectedWallet.value ?: return val ui = stateController.uiState.value val addressEntries = ContactAddressEntriesConverter().convert(ui.addresses) + val existing = loadedContact.value saveJob = modelScope.launch { - try { - // TODO: existing-contact update needs the loaded Contact; existing-contact loading is not implemented. - val result = saveContactInteractor.createContact( + val result = if (existing != null) { + saveContactInteractor.updateContact( + userWallet = userWallet, + contact = existing, + name = ui.name, + iconColor = ui.colors.selected.name, + addressEntries = addressEntries, + ) + } else { + saveContactInteractor.createContact( userWallet = userWallet, name = ui.name, iconColor = ui.colors.selected.name, addressEntries = addressEntries, ) - result.fold( - ifLeft = { error -> - handleSaveError(error) - analyticsSender.sendSaveErrorShown( - walletId = userWallet.walletId, - contactId = params.contactId?.value, - error = error, - ) - }, - ifRight = { contact -> + } + result.fold( + ifLeft = { error -> + handleSaveError(error) + analyticsSender.sendSaveErrorShown( + walletId = userWallet.walletId, + contactId = params.contactId?.value, + error = error, + ) + saveJob?.cancel() + refreshSaveButton() + }, + ifRight = { contact -> + if (existing == null) { analyticsSender.sendContactSaved( walletId = userWallet.walletId, contactId = contact.id.value, isEdit = params.contactId != null, ) - params.onBackClick() - }, - ) - } finally { - refreshSaveButton() - } + messageSender.send( + SnackbarMessage( + message = resourceReference(R.string.address_book_create_success_message), + startIconId = R.drawable.ic_success_20, + ), + ) + } + params.onBackClick() + }, + ) } refreshSaveButton() } + private fun onCloseClick() { + if (isDirty()) showDiscardDialog() else params.onBackClick() + } + + private fun onDeleteClick() { + messageSender.send( + DialogMessage( + message = resourceReference(R.string.address_book_delete_contact_description), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_delete), + isWarning = true, + onClick = ::deleteContact, + ) + }, + ), + ) + } + + private fun onAddressClick(address: ValidatedAddress) { + addressInfoNavigation.activate(address.address) + } + + fun createAddressInfoParams(address: String): DefaultAddressInfoComponent.Params { + val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address } + return DefaultAddressInfoComponent.Params( + address = address, + networkCount = entry?.networkIds?.size ?: 0, + onEditAddress = { onEditAddress(address) }, + onDeleteAddress = { onDeleteAddress(address) }, + onDismiss = { addressInfoNavigation.dismiss() }, + ) + } + + private fun onEditAddress(address: String) { + val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address } ?: return + val walletId = selectedWallet.value?.walletId?.stringValue ?: return + addressInfoNavigation.dismiss() + params.onAddAddressClick(walletId, params.contactId?.value, entry) + } + + private fun onDeleteAddress(address: String) { + addressInfoNavigation.dismiss() + val isLastAddress = stateController.uiState.value.addresses.size <= 1 + if (isLastAddress && params.contactId != null) { + onDeleteClick() + } else { + stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES)) + } + } + + // endregion + + // region Helpers + + private fun addAddress(address: ValidatedAddress) { + stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES)) + } + + private fun isWalletChangeable(wallets: List?): Boolean { + val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked } + return params.contactId == null && unlockedWalletsCount > 1 + } + + private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? { + if (name.isBlank()) return null + if (name == loadedContact.value?.name?.value) return null + val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null + if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null + return ContactNameErrorConverter().convert(error) + } + + 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, + isColdWallet = selectedWallet.value is UserWallet.Cold, + ), + ) + } + private fun handleSaveError(error: SaveContactError) { when (error) { is SaveContactError.Name -> stateController.update( @@ -256,53 +444,98 @@ internal class EditContactModel @Inject constructor( DialogMessage( title = resourceReference(R.string.common_something_went_wrong), message = resourceReference(R.string.address_book_creating_error), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_ok), + onClick = onDismissRequest, + ) + }, ), ) } } - 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), - ), + private fun deleteContact() { + val contactId = params.contactId ?: return + modelScope.launch { + deleteContactUseCase(contactId).fold( + ifLeft = { showDeleteError() }, + ifRight = { params.onBackClick() }, ) - } else { - analyticsSender.sendAddressScreenOpened() - params.onAddAddressClick() } } - private fun subscribeToConfirmedAddresses() { - resultHolder.confirmedAddress - .filterNotNull() - .onEach { address -> - addAddress(address) - resultHolder.clear() - } - .launchIn(modelScope) + private fun showDiscardDialog() { + messageSender.send( + DialogMessage( + title = resourceReference(R.string.address_book_unsaved_changes), + message = resourceReference(R.string.address_book_unsaved_changes_description), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.address_book_keep_editing), + onClick = onDismissRequest, + ) + }, + secondActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.address_book_discard), + isWarning = true, + onClick = params.onBackClick, + ) + }, + ), + ) } - private fun onNameChange(name: String) { - stateController.update(UpdateContactNameTransformer(name = name)) + private fun showDeleteError() { + messageSender.send( + DialogMessage( + title = resourceReference(R.string.common_something_went_wrong), + message = resourceReference(R.string.address_book_deleting_error), + ), + ) } - private fun onColorSelect(color: CryptoPortfolioIcon.Color) { - stateController.update(SelectContactColorTransformer(color = color)) + /** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */ + private fun isDirty(): Boolean { + val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline + return currentSnapshot() != baseline } - private fun addAddress(address: ValidatedAddress) { - stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES)) + private fun currentSnapshot(): EditSnapshot { + val ui = stateController.uiState.value + return EditSnapshot( + name = ui.name, + colorName = ui.colors.selected.name, + addresses = ui.addresses.map { it.address to it.networkIds.toSet() }, + ) } + private fun Contact.toSnapshot(): EditSnapshot = EditSnapshot( + name = name.value, + colorName = iconColor, + addresses = ContactAddressEntriesConverter().toValidatedAddresses(addressEntries) + .map { it.address to it.networkIds.toSet() }, + ) + + // endregion + + // region Models + private data class SaveButtonInputs( val name: String, val hasNameError: Boolean, val hasAddresses: Boolean, ) + private data class EditSnapshot( + val name: String, + val colorName: String, + val addresses: List>>, + ) + + // endregion + private companion object { const val MAX_ADDRESSES = 20 const val NAME_DEBOUNCE_MS = 300L 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 aad3317653..e5271406a7 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 @@ -52,7 +52,7 @@ internal class EditContactStateController @Inject constructor() { ), isAddAddressEnabled = true, saveButton = TangemButtonUM( - text = TextReference.Res(R.string.common_save), + text = TextReference.Res(R.string.address_book_save_contact), type = TangemButtonType.Primary, isEnabled = false, onClick = {}, @@ -60,6 +60,8 @@ internal class EditContactStateController @Inject constructor() { onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, + onAddressClick = {}, + onDeleteClick = null, ) } } \ 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 1ad80cf557..ebbac2f02a 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 @@ -4,19 +4,23 @@ import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.transformer.Transformer /** * Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by * [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state. */ +@Suppress("LongParameterList") internal class UpdateEditContactInitialStateTransformer( private val isExistingContact: Boolean, private val onNameChange: (String) -> Unit, private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, private val onCloseClick: () -> Unit, private val onAddAddressClick: () -> Unit, + private val onAddressClick: (ValidatedAddress) -> Unit, private val onSaveClick: () -> Unit, + private val onDeleteClick: (() -> Unit)?, ) : Transformer { override fun transform(prevState: EditContactUM): EditContactUM { @@ -32,6 +36,8 @@ internal class UpdateEditContactInitialStateTransformer( onNameChange = onNameChange, onCloseClick = onCloseClick, onAddAddressClick = onAddAddressClick, + onAddressClick = onAddressClick, + onDeleteClick = onDeleteClick, ) } } \ 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 index 90e13775ae..1949b41713 100644 --- 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 @@ -1,16 +1,24 @@ package com.tangem.features.addressbook.editcontact.state.transformers +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_logo_tangem_24 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, + private val isColdWallet: Boolean, ) : Transformer { override fun transform(prevState: EditContactUM): EditContactUM { return prevState.copy( - saveButton = prevState.saveButton.copy(isEnabled = isEnabled, isLoading = isLoading), + saveButton = prevState.saveButton.copy( + isEnabled = isEnabled, + isLoading = isLoading, + tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24).takeIf { isColdWallet }, + ), ) } } \ 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 5a1ca89b08..c722cb6e5f 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 @@ -42,6 +42,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_logo_tangem_24 import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM @@ -96,10 +97,13 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif shape = RoundedCornerShape(24.dp), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), ) { - ContactAddresses(addresses = state.addresses) + ContactAddresses(addresses = state.addresses, onAddressClick = state.onAddressClick) AddAddressRow(isEnabled = state.isAddAddressEnabled, onClick = state.onAddAddressClick) } WalletBlock(walletBlock = state.walletBlock) + state.onDeleteClick?.let { + DeleteContactButton(onClick = it) + } } Spacer(modifier = Modifier.weight(1f)) SaveButton(saveButton = state.saveButton) @@ -116,6 +120,7 @@ private fun SaveButton(saveButton: TangemButtonUM) { .fillMaxWidth() .padding(top = 16.dp), text = saveButton.text, + iconEnd = saveButton.tangemIconUM, onClick = saveButton.onClick, isEnabled = saveButton.isEnabled, isLoading = saveButton.isLoading, @@ -124,16 +129,17 @@ private fun SaveButton(saveButton: TangemButtonUM) { } @Composable -private fun ContactAddresses(addresses: ImmutableList) { +private fun ContactAddresses(addresses: ImmutableList, onAddressClick: (ValidatedAddress) -> Unit) { addresses.fastForEach { entry -> - AddressRow(entry = entry) + AddressRow(entry = entry, onClick = { onAddressClick(entry) }) } } @Composable -private fun AddressRow(entry: ValidatedAddress) { +private fun AddressRow(entry: ValidatedAddress, onClick: () -> Unit) { TangemRow( verticalAlignment = TangemRowVerticalAlignment.Center, + onClick = onClick, startSlot = { TangemIcon( tangemIconUM = TangemIconUM.Ident(text = entry.address), @@ -229,39 +235,72 @@ private fun AddAddressRow(isEnabled: Boolean, onClick: () -> Unit) { @Composable private fun WalletBlock(walletBlock: EditContactUM.WalletBlockUM, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .clickableSingle( + onClick = walletBlock.onClick, + enabled = walletBlock.isChangeable, + ) + .background(TangemTheme.colors3.bg.secondary), + ) { + TangemRow( + 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() + } + } + }, + ) + } + Text( + modifier = Modifier.padding(vertical = 10.dp, horizontal = 16.dp), + text = stringResourceSafe(R.string.address_book_save_wallet_to_description), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + ) + } +} + +@Composable +private fun DeleteContactButton(onClick: () -> Unit, modifier: Modifier = Modifier) { Box( modifier = modifier .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() + .clickableSingle(onClick = onClick) .background(TangemTheme.colors3.bg.secondary), + contentAlignment = Alignment.Center, ) { - 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() - } - } - }, + Text( + text = stringResourceSafe(R.string.address_book_delete_contact), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.status.error, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -419,11 +458,14 @@ private fun Preview_EditContactContent() { text = TextReference.Res(R.string.common_save), type = TangemButtonType.Primary, isEnabled = true, + tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24), onClick = {}, ), onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, + onAddressClick = {}, + onDeleteClick = {}, ), ) } 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 f8385617d1..8931859473 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 @@ -22,6 +22,8 @@ internal data class EditContactUM( val onNameChange: (String) -> Unit, val onCloseClick: () -> Unit, val onAddAddressClick: () -> Unit, + val onAddressClick: (ValidatedAddress) -> Unit, + val onDeleteClick: (() -> Unit)?, ) { data class Colors( 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 39e223b145..334be305e6 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 @@ -127,13 +127,14 @@ private fun ColumnScope.NothingFoundContent() { Column( modifier = Modifier .fillMaxWidth() + .imePadding() .weight(1f), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier - .size(80.dp) + .size(48.dp) .background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape), contentAlignment = Alignment.Center, ) { @@ -141,14 +142,14 @@ private fun ColumnScope.NothingFoundContent() { imageVector = Icons.ic_search_24, contentDescription = null, tint = TangemTheme.colors3.icon.secondary, - modifier = Modifier.size(28.dp), + modifier = Modifier.size(24.dp), ) } Text( modifier = Modifier.padding(top = 32.dp), - text = stringResourceSafe(R.string.common_no_results), - color = TangemTheme.colors3.text.primary, - style = TangemTheme.typography3.heading.small, + text = stringResourceSafe(R.string.address_book_search_no_results), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, ) } } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt index caa8ece2d7..25e4c2edf5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -27,16 +27,28 @@ internal sealed class AddressBookRoute { val predefinedNetworkId: String? = null, ) : AddressBookRoute() + /** + * Address entry screen. [walletId] / [excludeContactId] scope the `network + address` duplicate check to the target + * wallet (excluding the contact being edited). When [prefillAddress] is set the screen opens pre-filled (edit-address + * flow): [prefillNetworkIds] restores the previously chosen networks and [prefillMemo] the memo. + */ @Serializable - data object AddAddress : AddressBookRoute() + data class AddAddress( + val walletId: String? = null, + val excludeContactId: String? = null, + val prefillAddress: String? = null, + val prefillNetworkIds: kotlin.collections.List = emptyList(), + val prefillMemo: String? = null, + ) : AddressBookRoute() /** - * Network-selection screen for the [address] entered on [AddAddress]. [selectedNetworkIds] carries the current - * selection so it can be restored; empty means nothing is pre-selected. + * Network-selection screen. [matchedNetworkIds] are the networks the entered address already resolved to (computed + * once on the AddAddress screen and passed in, so this screen never re-validates the address against every chain). + * [selectedNetworkIds] carries the current selection so it can be restored; empty means nothing is pre-selected. */ @Serializable data class SelectNetworks( - val address: String, + val matchedNetworkIds: kotlin.collections.List, val selectedNetworkIds: kotlin.collections.List = emptyList(), ) : AddressBookRoute() diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt index f36705c5be..c18ccf281d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt @@ -29,7 +29,7 @@ internal class DefaultSelectNetworksComponent( } data class Params( - val address: String, + val matchedNetworkIds: List, val selectedNetworkIds: List, val onBackClick: () -> Unit, val onDone: (selectedNetworkIds: Set) -> Unit, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt index 594990b053..c91befa6e9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt @@ -1,11 +1,11 @@ package com.tangem.features.addressbook.selectnetworks.model import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.features.addressbook.common.SupportedNetworksMatcher import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer @@ -20,7 +20,6 @@ import javax.inject.Inject @ModelScoped internal class SelectNetworksModel @Inject constructor( paramsContainer: ParamsContainer, - supportedNetworksMatcher: SupportedNetworksMatcher, override val dispatchers: CoroutineDispatcherProvider, private val stateController: SelectNetworksStateController, ) : Model() { @@ -29,7 +28,7 @@ internal class SelectNetworksModel @Inject constructor( private val query = MutableStateFlow("") private val isSearchActive = MutableStateFlow(false) - private val matchedBlockchains: List = supportedNetworksMatcher.match(params.address) + private val matchedBlockchains: List = params.matchedNetworkIds.mapNotNull(Blockchain::fromNetworkId) private val selectedNetworks = MutableStateFlow( params.selectedNetworkIds.toSet().intersect( diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index 32bfd72b9f..9d473ed8d8 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -11,6 +11,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent @@ -43,6 +44,7 @@ internal class AddAddressModelTest { private val memoValidator: AddressMemoValidator = mockk() private val clipboardManager: ClipboardManager = mockk() private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk() + private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase = mockk() private val router: Router = mockk(relaxed = true) private val selectNetworksResultHolder = SelectNetworksResultHolder() @@ -50,8 +52,17 @@ internal class AddAddressModelTest { @BeforeEach fun resetMocks() { - clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router) + clearMocks( + supportedNetworksMatcher, + memoValidator, + clipboardManager, + listenToQrScanningUseCase, + checkAddressDuplicateUseCase, + router, + ) selectNetworksResultHolder.clear() + // Default: the network+address pair is free unless a test stubs an owning contact name. + coEvery { checkAddressDuplicateUseCase(any(), any(), any(), any()) } returns null // Default: an address matches nothing unless a test stubs a specific value. every { supportedNetworksMatcher.match(any()) } returns emptyList() // Default: any memo passes unless a test stubs an invalid one. @@ -114,7 +125,7 @@ internal class AddAddressModelTest { fun `GIVEN no matching network WHEN button clicked THEN onConfirm not called`() = runTest { // Arrange var confirmed: ValidatedAddress? = null - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) model.state.value.onAddressChange("0xABC") advanceUntilIdle() @@ -265,16 +276,16 @@ internal class AddAddressModelTest { } @Test - fun `GIVEN valid address WHEN onNetworkClick THEN opens selector with address and default selection`() = + fun `GIVEN valid address WHEN onNetworkClick THEN opens selector with matched networks and default selection`() = runTest { // Arrange - var openedAddress: String? = null + var openedMatched: List = emptyList() var openedSelection: List = listOf("sentinel") every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) val model = createModel( testScope = this, - onSelectNetworksClick = { address, selection -> - openedAddress = address + onSelectNetworksClick = { matched, selection -> + openedMatched = matched openedSelection = selection }, ) @@ -285,8 +296,9 @@ internal class AddAddressModelTest { // Act model.state.value.onNetworkClick() - // Assert — empty selection means "nothing selected yet" on the selection screen. - assertThat(openedAddress).isEqualTo(ADDRESS) + // Assert — the already-matched networks are handed over; empty selection = "nothing selected yet". + assertThat(openedMatched) + .containsExactly(Blockchain.Ethereum.toNetworkId(), Blockchain.BSC.toNetworkId()) assertThat(openedSelection).isEmpty() } @@ -296,7 +308,7 @@ internal class AddAddressModelTest { // Arrange var confirmed: ValidatedAddress? = null every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) advanceUntilIdle() model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() @@ -357,7 +369,7 @@ internal class AddAddressModelTest { // Arrange — a single match is auto-selected, so confirm works without opening the selection screen. var confirmed: ValidatedAddress? = null every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) advanceUntilIdle() model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() @@ -379,7 +391,7 @@ internal class AddAddressModelTest { // Arrange var confirmed: ValidatedAddress? = null every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) advanceUntilIdle() model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() @@ -433,7 +445,7 @@ internal class AddAddressModelTest { // Arrange var confirmed: ValidatedAddress? = null every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) advanceUntilIdle() model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() @@ -476,7 +488,7 @@ internal class AddAddressModelTest { // Arrange var confirmed: ValidatedAddress? = null every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) - val model = createModel(testScope = this, onConfirm = { confirmed = it }) + val model = createModel(testScope = this, onConfirm = { address, _ -> confirmed = address }) advanceUntilIdle() model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() @@ -547,11 +559,94 @@ internal class AddAddressModelTest { } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Prefill { + + @Test + fun `GIVEN prefilled address and networks WHEN created THEN field and selection restored`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel( + testScope = this, + params = params( + prefillAddress = ADDRESS, + prefillNetworkIds = listOf(Blockchain.Ethereum.toNetworkId(), Blockchain.BSC.toNetworkId()), + onConfirm = { address, _ -> confirmed = address }, + ), + ) + + // Act + advanceUntilIdle() + model.state.value.buttonUM.onClick() + + // Assert + assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS) + assertThat(confirmed?.networkIds) + .containsExactly(Blockchain.Ethereum.toNetworkId(), Blockchain.BSC.toNetworkId()) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class DuplicateAddress { + + @Test + fun `GIVEN pair already saved WHEN validated THEN inline error shown AND confirm blocked`() = runTest { + // Arrange — single match auto-selects, then the duplicate check reports an owning contact. + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) + coEvery { + checkAddressDuplicateUseCase(any(), Blockchain.Ethereum.toNetworkId(), ADDRESS, null) + } returns "Binance" + val model = createModel( + testScope = this, + params = params(walletId = "aa", onConfirm = { address, _ -> confirmed = address }), + ) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + model.state.value.buttonUM.onClick() + + // Assert + assertThat(model.state.value.addressField.isError).isTrue() + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + assertThat(confirmed).isNull() + } + } + + private fun params( + walletId: String? = null, + excludeContactId: String? = null, + prefillAddress: String? = null, + prefillNetworkIds: List = emptyList(), + prefillMemo: String? = null, + onSelectNetworksClick: (List, List) -> Unit = { _, _ -> }, + onConfirm: (ValidatedAddress, String?) -> Unit = { _, _ -> }, + ): DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( + walletId = walletId, + excludeContactId = excludeContactId, + prefillAddress = prefillAddress, + prefillNetworkIds = prefillNetworkIds, + prefillMemo = prefillMemo, + onBackClick = {}, + onSelectNetworksClick = onSelectNetworksClick, + onConfirm = onConfirm, + ) + private fun createModel( testScope: TestScope, - onConfirm: (ValidatedAddress) -> Unit = {}, - onSelectNetworksClick: (String, List) -> Unit = { _, _ -> }, + onConfirm: (ValidatedAddress, String?) -> Unit = { _, _ -> }, + onSelectNetworksClick: (List, List) -> Unit = { _, _ -> }, params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( + walletId = null, + excludeContactId = null, + prefillAddress = null, + prefillNetworkIds = emptyList(), + prefillMemo = null, onBackClick = {}, onSelectNetworksClick = onSelectNetworksClick, onConfirm = onConfirm, @@ -567,6 +662,7 @@ internal class AddAddressModelTest { clipboardManager = clipboardManager, stateController = AddAddressStateController(), selectNetworksResultHolder = selectNetworksResultHolder, + checkAddressDuplicateUseCase = checkAddressDuplicateUseCase, router = router, ).also { model = it } } 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 111e887f3b..0147a72235 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 @@ -11,21 +11,24 @@ 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.core.ui.message.SnackbarMessage import com.tangem.domain.addressbook.error.AddressBookSyncError 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.model.* +import com.tangem.domain.addressbook.usecase.DeleteContactUseCase +import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase 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.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.addressbook.common.AddressBookAnalyticsSender import com.tangem.features.addressbook.common.AddressBookResultHolder +import com.tangem.features.addressbook.common.ConfirmedAddress import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.state.EditContactStateController import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM @@ -33,11 +36,7 @@ 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 io.mockk.* import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,6 +58,8 @@ internal class EditContactModelTest { private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) private val validateContactNameUseCase: ValidateContactNameUseCase = mockk() private val saveContactInteractor: SaveContactInteractor = mockk() + private val getContactByIdUseCase: GetContactByIdUseCase = mockk() + private val deleteContactUseCase: DeleteContactUseCase = mockk() private val portfolioSelectorController: PortfolioSelectorController = mockk() private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true) private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk() @@ -77,6 +78,8 @@ internal class EditContactModelTest { coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right() every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData + // No existing contact by default; a StateFlow never surfaces a contact and never completes. + every { getContactByIdUseCase(any()) } returns MutableStateFlow(null) } @AfterEach @@ -120,6 +123,8 @@ internal class EditContactModelTest { onNameChange = state.onNameChange, onCloseClick = state.onCloseClick, onAddAddressClick = state.onAddAddressClick, + onAddressClick = state.onAddressClick, + onDeleteClick = null, ) assertThat(state).isEqualTo(expected) } @@ -202,7 +207,7 @@ internal class EditContactModelTest { val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) // Act - resultHolder.setConfirmedAddress(validatedAddress) + deliverConfirmed(validatedAddress) advanceUntilIdle() // Assert @@ -219,15 +224,34 @@ internal class EditContactModelTest { val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) // Act - resultHolder.setConfirmedAddress(validatedAddress) + deliverConfirmed(validatedAddress) advanceUntilIdle() - resultHolder.setConfirmedAddress(validatedAddress) + deliverConfirmed(validatedAddress) advanceUntilIdle() // Assert assertThat(model.state.value.addresses).containsExactly(validatedAddress) } + @Test + fun `GIVEN edit-address confirmed with replaces WHEN collected THEN old entry swapped for the new one`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + deliverConfirmed(ValidatedAddress(address = "0xOLD", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act — the edit-address flow confirms a new address that supersedes 0xOLD. + deliverConfirmed( + address = ValidatedAddress(address = "0xNEW", networkIds = persistentListOf("bsc")), + replaces = "0xOLD", + ) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addresses.map { it.address }).containsExactly("0xNEW") + } + @Test fun `GIVEN predefined address WHEN model created THEN address attached`() = runTest { // Arrange @@ -245,7 +269,12 @@ internal class EditContactModelTest { 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 })) + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val model = createModel( + testScope = this, + params = createParams(onAddAddressClick = { _, _, _ -> addClicked = true }), + ) advanceUntilIdle() // Act @@ -262,10 +291,13 @@ internal class EditContactModelTest { runTest { // Arrange var addClicked = false - val model = createModel(testScope = this, params = createParams(onAddAddressClick = { addClicked = true })) + val model = createModel( + testScope = this, + params = createParams(onAddAddressClick = { _, _, _ -> addClicked = true }), + ) advanceUntilIdle() repeat(MAX_ADDRESSES) { index -> - resultHolder.setConfirmedAddress( + deliverConfirmed( ValidatedAddress(address = "0x$index", networkIds = persistentListOf("ethereum")), ) advanceUntilIdle() @@ -344,6 +376,20 @@ internal class EditContactModelTest { assertThat(model.state.value.walletBlock.isChangeable).isFalse() } + @Test + fun `GIVEN cold wallet selected WHEN created THEN save button shows the Tangem logo`() = runTest { + // Arrange — MockUserWalletFactory builds a cold (card) wallet. + val coldWallet = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(coldWallet), selected = coldWallet) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert — a cold wallet signs via NFC, so the button carries the Tangem logo. + assertThat(model.state.value.saveButton.tangemIconUM).isNotNull() + } + @Test fun `GIVEN duplicate name in selected wallet WHEN name entered THEN name error shown`() = runTest { // Arrange @@ -415,7 +461,7 @@ internal class EditContactModelTest { // Act model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Assert @@ -449,7 +495,7 @@ internal class EditContactModelTest { // Act model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Assert @@ -467,7 +513,7 @@ internal class EditContactModelTest { val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true })) advanceUntilIdle() model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Act @@ -499,7 +545,7 @@ internal class EditContactModelTest { val model = createModel(testScope = this) advanceUntilIdle() model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Act @@ -520,7 +566,7 @@ internal class EditContactModelTest { val model = createModel(testScope = this, params = createParams(contactId = null)) advanceUntilIdle() model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Act @@ -543,7 +589,7 @@ internal class EditContactModelTest { val model = createModel(testScope = this, params = createParams(contactId = ContactId(value = "contact-id"))) advanceUntilIdle() model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Act @@ -566,7 +612,7 @@ internal class EditContactModelTest { val model = createModel(testScope = this) advanceUntilIdle() model.state.value.onNameChange("Satoshi") - resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) advanceUntilIdle() // Act @@ -578,10 +624,289 @@ internal class EditContactModelTest { .isEqualTo(resourceReference(R.string.address_book_name_taken_error)) } + @Test + fun `GIVEN save fails with backend error WHEN save clicked THEN button leaves loading AND editor stays`() = 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 + SaveContactError.Backend(AddressBookSyncError.Network).left() + val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true })) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert — the failed save must not leave the button spinning, and the editor stays open for a retry. + assertThat(model.state.value.saveButton.isLoading).isFalse() + assertThat(navigatedBack).isFalse() + } + + @Test + fun `GIVEN existing contact WHEN model created THEN name and addresses prefilled`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + + // Act + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Assert + val state = model.state.value + assertThat(state.name).isEqualTo("Alice") + assertThat(state.addresses.map { it.address }).containsExactly("0xABC") + } + + @Test + fun `GIVEN existing contact WHEN save clicked THEN updateContact called instead of create`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + coEvery { saveContactInteractor.updateContact(any(), any(), any(), any(), any()) } returns contact.right() + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Act + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { saveContactInteractor.updateContact(walletA, contact, "Alice", any(), any()) } + coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) } + } + + @Test + fun `GIVEN new contact saved WHEN success THEN contact-added snackbar shown`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + // relaxed so the merged analytics call (contact.id.value) doesn't throw before the snackbar is sent. + coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns + mockk(relaxed = true).right() + val model = createModel(testScope = this) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert + verify { messageSender.send(any()) } + } + + @Test + fun `GIVEN existing contact WHEN delete confirmed THEN deleteContactUseCase called AND navigates back`() = runTest { + // Arrange + var navigatedBack = false + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right() + val model = createModel( + testScope = this, + params = createParams(contactId = ContactId("c-1"), onBackClick = { navigatedBack = true }), + ) + advanceUntilIdle() + + // Act — invoke the delete action, then confirm on the captured dialog. + model.state.value.onDeleteClick?.invoke() + val dialog = slot() + verify { messageSender.send(capture(dialog)) } + dialog.captured.firstAction.onClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { deleteContactUseCase(ContactId("c-1")) } + assertThat(navigatedBack).isTrue() + } + + @Test + fun `GIVEN delete fails WHEN confirmed THEN error dialog shown AND stays`() = runTest { + // Arrange + var navigatedBack = false + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + coEvery { deleteContactUseCase(ContactId("c-1")) } returns AddressBookSyncError.Network.left() + val model = createModel( + testScope = this, + params = createParams(contactId = ContactId("c-1"), onBackClick = { navigatedBack = true }), + ) + advanceUntilIdle() + + // Act + model.state.value.onDeleteClick?.invoke() + val dialog = slot() + verify { messageSender.send(capture(dialog)) } + dialog.captured.firstAction.onClick() + advanceUntilIdle() + + // Assert + assertThat(navigatedBack).isFalse() + // Two dialogs sent: the confirmation and the error. + verify(atLeast = 2) { messageSender.send(any()) } + } + + @Test + fun `GIVEN unchanged new contact WHEN close clicked THEN navigates back without dialog`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + var navigatedBack = false + val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true })) + advanceUntilIdle() + + // Act + model.state.value.onCloseClick() + + // Assert + assertThat(navigatedBack).isTrue() + verify(exactly = 0) { messageSender.send(any()) } + } + + @Test + fun `GIVEN edited name WHEN close clicked THEN discard dialog shown AND not navigated`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + var navigatedBack = false + val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true })) + advanceUntilIdle() + model.state.value.onNameChange("Satoshi") + advanceUntilIdle() + + // Act + model.state.value.onCloseClick() + + // Assert + assertThat(navigatedBack).isFalse() + verify { messageSender.send(any()) } + } + + @Test + fun `GIVEN existing contact with one address WHEN it is deleted THEN contact deletion is offered not silent removal`() = + runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right() + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Act — delete the only address from the address-info sheet. + model.createAddressInfoParams("0xABC").onDeleteAddress() + advanceUntilIdle() + + // Assert — not silently removed; a confirmation is shown, and confirming deletes the whole contact. + assertThat(model.state.value.addresses.map { it.address }).containsExactly("0xABC") + val dialog = slot() + verify { messageSender.send(capture(dialog)) } + dialog.captured.firstAction.onClick() + advanceUntilIdle() + coVerify(exactly = 1) { deleteContactUseCase(ContactId("c-1")) } + } + + @Test + fun `GIVEN existing contact with several addresses WHEN one is deleted THEN it is removed and the rest kept`() = + runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xAAA").copy( + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("e-1"), + address = "0xAAA", + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = null, + signature = "sig", + ), + AddressEntry( + id = AddressEntryId("e-2"), + address = "0xBBB", + networkId = Network.RawID("bsc"), + networkName = "BSC", + memo = null, + signature = "sig", + ), + ), + ) + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Act + model.createAddressInfoParams("0xAAA").onDeleteAddress() + advanceUntilIdle() + + // Assert — plain removal, no contact-deletion prompt. + assertThat(model.state.value.addresses.map { it.address }).containsExactly("0xBBB") + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { deleteContactUseCase(any()) } + } + + @Test + fun `GIVEN new contact with one address WHEN it is deleted THEN removed without a contact-deletion prompt`() = + runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + setupWallets(wallets = listOf(walletA), selected = walletA) + val model = createModel(testScope = this) + advanceUntilIdle() + deliverConfirmed(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))) + advanceUntilIdle() + + // Act + model.createAddressInfoParams("0xABC").onDeleteAddress() + advanceUntilIdle() + + // Assert — a new (unsaved) contact has nothing to delete, so the address is just removed. + assertThat(model.state.value.addresses).isEmpty() + verify(exactly = 0) { messageSender.send(any()) } + } + + private fun existingContact(walletId: String, name: String, address: String): Contact = Contact( + id = ContactId("c-1"), + walletId = UserWalletId(walletId), + name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = CryptoPortfolioIcon.Color.Azure.name, + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("e-1"), + address = address, + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = null, + signature = "sig", + ), + ), + ) + private fun createParams( contactId: ContactId? = null, predefinedAddress: ValidatedAddress? = null, - onAddAddressClick: () -> Unit = {}, + onAddAddressClick: (String, String?, ValidatedAddress?) -> Unit = { _, _, _ -> }, onBackClick: () -> Unit = {}, ): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params( contactId = contactId, @@ -604,6 +929,8 @@ internal class EditContactModelTest { userWalletsListRepository = userWalletsListRepository, validateContactNameUseCase = validateContactNameUseCase, saveContactInteractor = saveContactInteractor, + getContactByIdUseCase = getContactByIdUseCase, + deleteContactUseCase = deleteContactUseCase, analyticsSender = analyticsSender, portfolioSelectorController = portfolioSelectorController, portfolioFetcherFactory = portfolioFetcherFactory, @@ -618,6 +945,11 @@ internal class EditContactModelTest { private fun createWallet(id: String, name: String): UserWallet = MockUserWalletFactory.create().copy(walletId = UserWalletId(id), name = name) + /** Mirrors what the AddAddress screen delivers back through the result holder. */ + private fun deliverConfirmed(address: ValidatedAddress, replaces: String? = null) { + resultHolder.setConfirmedAddress(ConfirmedAddress(address = address, replaces = replaces)) + } + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { val testDispatcher = StandardTestDispatcher(testScheduler) return TestingCoroutineDispatcherProvider( diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt index 2a8fd39c9e..11ace5d9b1 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt @@ -5,13 +5,9 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.features.addressbook.common.SupportedNetworksMatcher import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -23,20 +19,11 @@ import org.junit.jupiter.api.* @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class SelectNetworksModelTest { - private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk() - private val ethereum = Blockchain.Ethereum private val bsc = Blockchain.BSC private var model: SelectNetworksModel? = null - @BeforeEach - fun resetMocks() { - clearMocks(supportedNetworksMatcher) - // The address resolves to two networks unless a test overrides it. - every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(ethereum, bsc) - } - @AfterEach fun tearDown() { model?.onDestroy() @@ -149,7 +136,7 @@ internal class SelectNetworksModelTest { selectedNetworkIds: List = emptyList(), onDone: (Set) -> Unit = {}, params: DefaultSelectNetworksComponent.Params = DefaultSelectNetworksComponent.Params( - address = ADDRESS, + matchedNetworkIds = listOf(ethereum.toNetworkId(), bsc.toNetworkId()), selectedNetworkIds = selectedNetworkIds, onBackClick = {}, onDone = onDone, @@ -159,7 +146,6 @@ internal class SelectNetworksModelTest { return SelectNetworksModel( paramsContainer = paramsContainer, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - supportedNetworksMatcher = supportedNetworksMatcher, stateController = SelectNetworksStateController(), ).also { model = it } } @@ -174,8 +160,4 @@ internal class SelectNetworksModelTest { single = testDispatcher, ) } - - private companion object { - const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" - } } \ No newline at end of file