Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 12:58:53 +02:00
parent 27031abb7d
commit 889f47acdb
22 changed files with 1058 additions and 259 deletions

View file

@ -30,8 +30,13 @@ internal class DefaultAddAddressComponent(
}
data class Params(
val walletId: String?,
val excludeContactId: String?,
val prefillAddress: String?,
val prefillNetworkIds: List<String>,
val prefillMemo: String?,
val onBackClick: () -> Unit,
val onSelectNetworksClick: (address: String, selectedNetworkIds: List<String>) -> Unit,
val onConfirm: (ValidatedAddress) -> Unit,
val onSelectNetworksClick: (matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) -> Unit,
val onConfirm: (address: ValidatedAddress, replaces: String?) -> Unit,
)
}

View file

@ -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<String?> = 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,
)
}

View file

@ -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<Blockchain>,
private val selectedBlockchains: List<Blockchain>,
private val isMemoInvalid: Boolean,
private val duplicateName: String?,
) : Transformer<AddAddressUM> {
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),
)
}

View file

@ -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,

View file

@ -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<String>)
fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>)
fun onSelectNetworksBack()

View file

@ -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<ValidatedAddress?>
field = MutableStateFlow<ValidatedAddress?>(null)
val confirmedAddress: StateFlow<ConfirmedAddress?>
field = MutableStateFlow<ConfirmedAddress?>(null)
fun setConfirmedAddress(address: ValidatedAddress) {
confirmedAddress.value = address
fun setConfirmedAddress(confirmed: ConfirmedAddress) {
confirmedAddress.value = confirmed
}
fun clear() {
confirmedAddress.value = null
}
}
}
internal data class ConfirmedAddress(
val address: ValidatedAddress,
val replaces: String?,
)

View file

@ -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<String>) {
override fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) {
navigation.pushNew(
AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds),
AddressBookRoute.SelectNetworks(
matchedNetworkIds = matchedNetworkIds,
selectedNetworkIds = selectedNetworkIds,
),
)
}

View file

@ -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
}

View file

@ -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,
)
}

View file

@ -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<UserWalletId?>(null)
/** The contact being edited (null for a new contact). Drives create-vs-update and the delete/discard rules. */
private val loadedContact = MutableStateFlow<Contact?>(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<EditContactUM> get() = stateController.uiState
val portfolioSelectorNavigation = SlotNavigation<Unit>()
val addressInfoNavigation = SlotNavigation<String>()
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<UserWallet?> =
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<UserWallet?> = 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<UserWallet>?): 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<UserWallet>?): 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<Pair<String, Set<String>>>,
)
// endregion
private companion object {
const val MAX_ADDRESSES = 20
const val NAME_DEBOUNCE_MS = 300L

View file

@ -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,
)
}
}

View file

@ -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<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
@ -32,6 +36,8 @@ internal class UpdateEditContactInitialStateTransformer(
onNameChange = onNameChange,
onCloseClick = onCloseClick,
onAddAddressClick = onAddAddressClick,
onAddressClick = onAddressClick,
onDeleteClick = onDeleteClick,
)
}
}

View file

@ -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<EditContactUM> {
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 },
),
)
}
}

View file

@ -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<ValidatedAddress>) {
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>, 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 = {},
),
)
}

View file

@ -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(

View file

@ -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,
)
}
}

View file

@ -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<String> = 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<String>,
val selectedNetworkIds: kotlin.collections.List<String> = emptyList(),
) : AddressBookRoute()

View file

@ -29,7 +29,7 @@ internal class DefaultSelectNetworksComponent(
}
data class Params(
val address: String,
val matchedNetworkIds: List<String>,
val selectedNetworkIds: List<String>,
val onBackClick: () -> Unit,
val onDone: (selectedNetworkIds: Set<String>) -> Unit,

View file

@ -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<Blockchain> = supportedNetworksMatcher.match(params.address)
private val matchedBlockchains: List<Blockchain> = params.matchedNetworkIds.mapNotNull(Blockchain::fromNetworkId)
private val selectedNetworks = MutableStateFlow(
params.selectedNetworkIds.toSet().intersect(

View file

@ -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<String> = emptyList()
var openedSelection: List<String> = 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<String> = emptyList(),
prefillMemo: String? = null,
onSelectNetworksClick: (List<String>, List<String>) -> 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<String>) -> Unit = { _, _ -> },
onConfirm: (ValidatedAddress, String?) -> Unit = { _, _ -> },
onSelectNetworksClick: (List<String>, List<String>) -> 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 }
}

View file

@ -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<null> never surfaces a contact and never completes.
every { getContactByIdUseCase(any()) } returns MutableStateFlow<Contact?>(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<Contact>(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<SnackbarMessage>()) }
}
@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<DialogMessage>()
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<DialogMessage>()
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<DialogMessage>()) }
}
@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<DialogMessage>()) }
}
@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<DialogMessage>()) }
}
@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<DialogMessage>()
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<DialogMessage>()) }
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<DialogMessage>()) }
}
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(

View file

@ -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<String> = emptyList(),
onDone: (Set<String>) -> 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"
}
}