Updated on 2026-08-14
This commit is contained in:
parent
27031abb7d
commit
889f47acdb
22 changed files with 1058 additions and 259 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue