Updated on 2026-08-14
This commit is contained in:
parent
9af7bad7b8
commit
909cb765de
16 changed files with 802 additions and 117 deletions
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Address-book analytics that are triggered from outside the address-book feature (i.e. from the Send flow).
|
||||
*
|
||||
* The [com.tangem.features.addressbook.analytics] events are internal to the address-book impl module, so the Send
|
||||
* feature cannot construct them directly — it reports the address-book funnel through this contract instead.
|
||||
*/
|
||||
interface AddressBookSendAnalytics {
|
||||
|
||||
/**
|
||||
* Fired when the recipient address (and memo, if any) picked from the address book has been substituted into the
|
||||
* Send form — the final step of choosing a recipient from the book.
|
||||
*
|
||||
* @param walletId the current (sending) wallet
|
||||
* @param contactId id of the picked contact
|
||||
*/
|
||||
fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddre
|
|||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.AddressMemoValidator
|
||||
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
||||
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
||||
|
|
@ -46,6 +47,7 @@ internal class AddAddressModel @Inject constructor(
|
|||
private val stateController: AddAddressStateController,
|
||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val router: Router,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -110,11 +112,13 @@ internal class AddAddressModel @Inject constructor(
|
|||
init {
|
||||
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
||||
selectNetworksResultHolder.clear()
|
||||
sendInitAnalytics()
|
||||
updateInitialState()
|
||||
subscribeToValidation()
|
||||
subscribeToMemoValidation()
|
||||
subscribeToSelectedNetworks()
|
||||
subscribeToQrScanResult()
|
||||
subscribeToAddressInvalid()
|
||||
prefillData()
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +193,22 @@ internal class AddAddressModel @Inject constructor(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun subscribeToAddressInvalid() {
|
||||
val walletId = params.walletId ?: return
|
||||
validation
|
||||
.map { it.address.isNotBlank() && it.matchedBlockchains.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.filter { isInvalid -> isInvalid }
|
||||
.onEach {
|
||||
analyticsSender.sendAddressInvalid(
|
||||
walletId = UserWalletId(walletId),
|
||||
contactId = params.excludeContactId.orEmpty(),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
||||
}
|
||||
|
|
@ -258,6 +278,10 @@ internal class AddAddressModel @Inject constructor(
|
|||
return matched.filter { it.toNetworkId() in selected }
|
||||
}
|
||||
|
||||
private fun sendInitAnalytics() {
|
||||
analyticsSender.sendAddressScreenOpened()
|
||||
}
|
||||
|
||||
private data class AddressValidation(
|
||||
val address: String,
|
||||
val matchedBlockchains: List<Blockchain>,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ private const val ADDRESS_BOOK_CATEGORY = "Address Book"
|
|||
private const val WALLET_ID = "Wallet Id"
|
||||
private const val CONTACT_ID = "Contact Id"
|
||||
private const val MODE = "Mode"
|
||||
private const val CONTACTS_COUNT = "Contacts Count"
|
||||
|
||||
sealed class AddressBookEvents(
|
||||
event: String,
|
||||
|
|
@ -24,11 +25,13 @@ sealed class AddressBookEvents(
|
|||
class ContactListScreenOpened(
|
||||
walletId: UserWalletId,
|
||||
source: Source,
|
||||
contactsCount: Int,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact List Screen Opened",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId.stringValue,
|
||||
SOURCE to source.value,
|
||||
CONTACTS_COUNT to contactsCount.toString(),
|
||||
),
|
||||
) {
|
||||
enum class Source(val value: String) {
|
||||
|
|
@ -92,11 +95,11 @@ sealed class AddressBookEvents(
|
|||
// endregion
|
||||
|
||||
// region Contact editing
|
||||
class ContactOpened(
|
||||
class ContactScreenOpened(
|
||||
walletId: UserWalletId,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Opened",
|
||||
event = "Contact Screen Opened",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId.stringValue,
|
||||
CONTACT_ID to contactId,
|
||||
|
|
@ -116,7 +119,7 @@ sealed class AddressBookEvents(
|
|||
walletId: UserWalletId,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Selected In Send",
|
||||
event = "Contact Selected",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId.stringValue,
|
||||
CONTACT_ID to contactId,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
|
||||
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -21,12 +23,13 @@ internal class ContactsBlockModel @Inject constructor(
|
|||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private var isWidgetShownReported = false
|
||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||
|
||||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
|
|
@ -38,17 +41,30 @@ internal class ContactsBlockModel @Inject constructor(
|
|||
) { contacts, wallets -> contacts to wallets.values.toList() }
|
||||
.onEach { (contacts, wallets) ->
|
||||
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
||||
reportWidgetShownIfNeeded(matched.isNotEmpty())
|
||||
stateController.update(
|
||||
UpdateContactsBlockStateTransformer(
|
||||
matched = matched,
|
||||
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
|
||||
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
|
||||
onSeeAllClick = params.onSeeAllClick,
|
||||
onContactClick = params.onContactClick,
|
||||
onContactClick = ::onContactClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun reportWidgetShownIfNeeded(isVisible: Boolean) {
|
||||
if (isVisible && !isWidgetShownReported) {
|
||||
isWidgetShownReported = true
|
||||
analyticsSender.sendSendFlowWidgetShown(scope = modelScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContactClick(contact: MatchedContact) {
|
||||
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
|
||||
params.onContactClick(contact)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.addressbook.AddressBookSendAnalytics
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -20,21 +21,75 @@ import javax.inject.Singleton
|
|||
internal class AddressBookAnalyticsSender @Inject constructor(
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
) {
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
) : AddressBookSendAnalytics {
|
||||
|
||||
fun sendContactListScreenOpened(mode: AddressBookOpenMode, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
fun sendContactListScreenOpened(source: Source, contactsCount: Int, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactListScreenOpened(
|
||||
walletId = selectedWalletId(),
|
||||
source = mode.toAnalyticsSource(),
|
||||
source = source,
|
||||
contactsCount = contactsCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendContactScreenOpened(contactId: String, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactScreenOpened(walletId = selectedWalletId(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSendFlowWidgetShown(scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(AddressBookEvents.SendFlowWidgetShown(walletId = selectedWalletId()))
|
||||
}
|
||||
}
|
||||
|
||||
fun sendContactSelectedInSend(contactId: String, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactSelectedInSend(walletId = selectedWalletId(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressSubstitutedInSend(walletId = walletId, contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAddressInvalid(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressInvalid(walletId = walletId, contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendDuplicateNameErrorShown(walletId: UserWalletId, contactId: String?) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.DuplicateNameErrorShown(walletId = walletId, contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAddressRemoved(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressRemoved(walletId = walletId, contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendContactDeleted(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactDeleted(walletId = walletId, contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddContactTapped(
|
||||
walletId = selectedWalletId(),
|
||||
|
|
@ -99,11 +154,4 @@ internal class AddressBookAnalyticsSender @Inject constructor(
|
|||
.filterNotNull()
|
||||
.first()
|
||||
.walletId
|
||||
|
||||
private fun AddressBookOpenMode.toAnalyticsSource(): Source = when (this) {
|
||||
AddressBookOpenMode.Default -> Source.Settings
|
||||
is AddressBookOpenMode.ContactSelection,
|
||||
is AddressBookOpenMode.WithContactCreation,
|
||||
-> Source.SendFlow
|
||||
}
|
||||
}
|
||||
|
|
@ -39,8 +39,9 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
// Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
|
||||
resultHolder.clear()
|
||||
selectNetworksResultHolder.clear()
|
||||
|
||||
analyticsSender.sendContactListScreenOpened(mode = params.addressBookOpenMode, scope = componentScope)
|
||||
if (params.addressBookOpenMode is AddressBookOpenMode.WithContactCreation) {
|
||||
analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = componentScope)
|
||||
}
|
||||
}
|
||||
|
||||
private val clickIntents = object : AddressBookClickIntents {
|
||||
|
|
@ -50,11 +51,14 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onAddContactClick() {
|
||||
analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = componentScope)
|
||||
navigation.pushNew(AddressBookRoute.EditContact())
|
||||
}
|
||||
|
||||
override fun onEditContactBack() {
|
||||
navigation.pop()
|
||||
navigation.pop { isPopped ->
|
||||
if (!isPopped) router.pop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) {
|
||||
|
|
@ -132,8 +136,6 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
|
||||
)
|
||||
is AddressBookOpenMode.WithContactCreation -> listOf(
|
||||
AddressBookRoute.List(),
|
||||
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
|
||||
AddressBookRoute.EditContact(
|
||||
predefinedAddress = mode.address,
|
||||
predefinedNetworkId = mode.networkId,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
package com.tangem.features.addressbook.di
|
||||
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.*
|
||||
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
|
||||
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
|
||||
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
|
||||
import dagger.Binds
|
||||
|
|
@ -42,4 +39,8 @@ internal interface AddressBookComponentModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAddressBookSendAnalytics(impl: AddressBookAnalyticsSender): AddressBookSendAnalytics
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.core.ui.message.EventMessageAction
|
||||
|
|
@ -132,12 +131,7 @@ internal class EditContactModel @Inject constructor(
|
|||
observeNameValidation()
|
||||
observeSaveButton()
|
||||
loadExistingContact()
|
||||
sendAddContactTappedEvent()
|
||||
}
|
||||
|
||||
private fun sendAddContactTappedEvent() {
|
||||
if (params.contactId != null) return
|
||||
analyticsSender.sendAddContactTapped(fromSendSuccess = params.predefinedAddress != null, scope = modelScope)
|
||||
sendContactScreenOpenedEvent()
|
||||
}
|
||||
|
||||
// region Initialization
|
||||
|
|
@ -231,9 +225,18 @@ internal class EditContactModel @Inject constructor(
|
|||
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) }
|
||||
.mapLatest { (name, walletId) -> walletId to validateName(name, walletId) }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (walletId, error) ->
|
||||
if (error == ContactNameValidationError.Duplicate) {
|
||||
analyticsSender.sendDuplicateNameErrorShown(
|
||||
walletId = walletId,
|
||||
contactId = params.contactId?.value,
|
||||
)
|
||||
}
|
||||
stateController.update(UpdateNameErrorTransformer(error?.let(ContactNameErrorConverter()::convert)))
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +293,6 @@ internal class EditContactModel @Inject constructor(
|
|||
)
|
||||
} else {
|
||||
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
|
||||
analyticsSender.sendAddressScreenOpened()
|
||||
params.onAddAddressClick(walletId, params.contactId?.value, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -332,11 +334,6 @@ internal class EditContactModel @Inject constructor(
|
|||
},
|
||||
ifRight = { contact ->
|
||||
if (existing == null) {
|
||||
analyticsSender.sendContactSaved(
|
||||
walletId = userWallet.walletId,
|
||||
contactId = contact.id.value,
|
||||
isEdit = params.contactId != null,
|
||||
)
|
||||
messageSender.send(
|
||||
SnackbarMessage(
|
||||
message = resourceReference(R.string.address_book_create_success_message),
|
||||
|
|
@ -344,6 +341,11 @@ internal class EditContactModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
}
|
||||
analyticsSender.sendContactSaved(
|
||||
walletId = userWallet.walletId,
|
||||
contactId = contact.id.value,
|
||||
isEdit = params.contactId != null,
|
||||
)
|
||||
params.onBackClick()
|
||||
},
|
||||
)
|
||||
|
|
@ -356,6 +358,10 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onDeleteClick() {
|
||||
showDeleteContactDialog(fromLastAddressRemoval = false)
|
||||
}
|
||||
|
||||
private fun showDeleteContactDialog(fromLastAddressRemoval: Boolean) {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
message = resourceReference(R.string.address_book_delete_contact_description),
|
||||
|
|
@ -363,7 +369,7 @@ internal class EditContactModel @Inject constructor(
|
|||
EventMessageAction(
|
||||
title = resourceReference(R.string.common_delete),
|
||||
isWarning = true,
|
||||
onClick = ::deleteContact,
|
||||
onClick = { deleteContact(fromLastAddressRemoval = fromLastAddressRemoval) },
|
||||
)
|
||||
},
|
||||
),
|
||||
|
|
@ -396,8 +402,12 @@ internal class EditContactModel @Inject constructor(
|
|||
addressInfoNavigation.dismiss()
|
||||
val isLastAddress = stateController.uiState.value.addresses.size <= 1
|
||||
if (isLastAddress && params.contactId != null) {
|
||||
onDeleteClick()
|
||||
// Removing the last address deletes the whole contact — analytics is emitted from the confirmed delete.
|
||||
showDeleteContactDialog(fromLastAddressRemoval = true)
|
||||
} else {
|
||||
contactWalletId()?.let { walletId ->
|
||||
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = params.contactId?.value.orEmpty())
|
||||
}
|
||||
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
}
|
||||
|
|
@ -406,6 +416,10 @@ internal class EditContactModel @Inject constructor(
|
|||
|
||||
// region Helpers
|
||||
|
||||
private fun sendContactScreenOpenedEvent() {
|
||||
analyticsSender.sendContactScreenOpened(contactId = params.contactId?.value.orEmpty(), scope = modelScope)
|
||||
}
|
||||
|
||||
private fun addAddress(address: ValidatedAddress) {
|
||||
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
|
|
@ -415,12 +429,12 @@ internal class EditContactModel @Inject constructor(
|
|||
return params.contactId == null && unlockedWalletsCount > 1
|
||||
}
|
||||
|
||||
private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? {
|
||||
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
|
||||
if (name.isBlank()) return null
|
||||
if (name == loadedContact.value?.name?.value) return null
|
||||
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
|
||||
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
|
||||
return ContactNameErrorConverter().convert(error)
|
||||
return error
|
||||
}
|
||||
|
||||
private fun refreshSaveButton() {
|
||||
|
|
@ -456,16 +470,28 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun deleteContact() {
|
||||
private fun deleteContact(fromLastAddressRemoval: Boolean) {
|
||||
val contactId = params.contactId ?: return
|
||||
val walletId = contactWalletId()
|
||||
// The address removal precedes the backend delete; the contact-deleted event follows a successful response.
|
||||
if (fromLastAddressRemoval && walletId != null) {
|
||||
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = contactId.value)
|
||||
}
|
||||
modelScope.launch {
|
||||
deleteContactUseCase(contactId).fold(
|
||||
ifLeft = { showDeleteError() },
|
||||
ifRight = { params.onBackClick() },
|
||||
ifRight = {
|
||||
if (walletId != null) {
|
||||
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
|
||||
}
|
||||
params.onBackClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contactWalletId(): UserWalletId? = loadedContact.value?.walletId ?: selectedWallet.value?.walletId
|
||||
|
||||
private fun showDiscardDialog() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
|||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
||||
|
|
@ -42,6 +44,7 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val stateController: AddressBookListStateController,
|
||||
private val router: Router,
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -87,6 +90,14 @@ internal class AddressBookListModel @Inject constructor(
|
|||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
|
||||
sendContactListScreenOpenedEvent()
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
contactSelectionTrigger.trigger(contact)
|
||||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
}
|
||||
|
||||
private fun updateState(inputs: ListInputs) {
|
||||
|
|
@ -134,6 +145,8 @@ internal class AddressBookListModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onPickContact(contact: MatchedContact) {
|
||||
// Reported on tap, before the address is substituted; onPickContact is only invoked in selector (Send) mode.
|
||||
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
|
||||
val singleEntry = contact.entries.singleOrNull()
|
||||
if (singleEntry != null) {
|
||||
deliverSelection(contact.toSelectedContact(singleEntry))
|
||||
|
|
@ -142,10 +155,22 @@ internal class AddressBookListModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
contactSelectionTrigger.trigger(contact)
|
||||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
private fun sendContactListScreenOpenedEvent() {
|
||||
allContacts
|
||||
.take(count = 1)
|
||||
.onEach { contacts ->
|
||||
analyticsSender.sendContactListScreenOpened(
|
||||
source = params.mode.toAnalyticsSource(),
|
||||
contactsCount = contacts.size,
|
||||
scope = modelScope,
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun AddressBookRoute.ListMode.toAnalyticsSource(): Source = when (this) {
|
||||
AddressBookRoute.ListMode.Default -> Source.Settings
|
||||
is AddressBookRoute.ListMode.Selector -> Source.SendFlow
|
||||
}
|
||||
|
||||
private data class ListInputs(
|
||||
|
|
|
|||
|
|
@ -12,21 +12,19 @@ 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.models.wallet.UserWalletId
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.AddressMemoValidator
|
||||
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
||||
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import io.mockk.*
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -45,6 +43,7 @@ internal class AddAddressModelTest {
|
|||
private val clipboardManager: ClipboardManager = mockk()
|
||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
|
||||
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase = mockk()
|
||||
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val selectNetworksResultHolder = SelectNetworksResultHolder()
|
||||
|
||||
|
|
@ -58,6 +57,7 @@ internal class AddAddressModelTest {
|
|||
clipboardManager,
|
||||
listenToQrScanningUseCase,
|
||||
checkAddressDuplicateUseCase,
|
||||
analyticsSender,
|
||||
router,
|
||||
)
|
||||
selectNetworksResultHolder.clear()
|
||||
|
|
@ -618,6 +618,81 @@ internal class AddAddressModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN model created THEN AddressScreenOpened sent`() = runTest {
|
||||
// Act
|
||||
createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the add-address screen reports itself opened on creation, not the button that navigates to it.
|
||||
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class AddressInvalidEvent {
|
||||
|
||||
@Test
|
||||
fun `GIVEN edit mode WHEN address matches no network THEN AddressInvalid sent with contactId`() = runTest {
|
||||
// Arrange
|
||||
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
|
||||
val model = createModel(testScope = this, params = params(walletId = "aa", excludeContactId = "c-1"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(ADDRESS)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendAddressInvalid(walletId = UserWalletId("aa"), contactId = "c-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN create mode WHEN address matches no network THEN AddressInvalid sent with empty contactId`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
|
||||
val model = createModel(testScope = this, params = params(walletId = "aa", excludeContactId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(ADDRESS)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendAddressInvalid(walletId = UserWalletId("aa"), contactId = "") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid address WHEN typed THEN AddressInvalid not sent`() = runTest {
|
||||
// Arrange
|
||||
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
|
||||
val model = createModel(testScope = this, params = params(walletId = "aa"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(ADDRESS)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) { analyticsSender.sendAddressInvalid(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty field WHEN cleared THEN AddressInvalid not sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(testScope = this, params = params(walletId = "aa"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange("")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — a blank field is not a validation failure.
|
||||
verify(exactly = 0) { analyticsSender.sendAddressInvalid(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun params(
|
||||
walletId: String? = null,
|
||||
excludeContactId: String? = null,
|
||||
|
|
@ -663,6 +738,7 @@ internal class AddAddressModelTest {
|
|||
stateController = AddAddressStateController(),
|
||||
selectNetworksResultHolder = selectNetworksResultHolder,
|
||||
checkAddressDuplicateUseCase = checkAddressDuplicateUseCase,
|
||||
analyticsSender = analyticsSender,
|
||||
router = router,
|
||||
).also { model = it }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
package com.tangem.features.addressbook.block.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
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.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ContactsBlockModelTest {
|
||||
|
||||
private val getContactsUseCase: GetContactsUseCase = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||
private val network: Network = mockk { every { rawId } returns ETHEREUM }
|
||||
|
||||
private var model: ContactsBlockModel? = null
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(getContactsUseCase, getWalletsUseCase, analyticsSender)
|
||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||
flowOf(linkedMapOf<UserWalletId, UserWallet>())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
model?.onDestroy()
|
||||
model = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN matching contacts WHEN block populated THEN SendFlowWidgetShown sent once`() = runTest {
|
||||
// Arrange
|
||||
every { getContactsUseCase(query = any(), userWalletId = null) } returns
|
||||
flowOf(listOf(contact(id = "1"), contact(id = "2")))
|
||||
|
||||
// Act
|
||||
createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — reported once even though two contacts populate the block.
|
||||
verify(exactly = 1) { analyticsSender.sendSendFlowWidgetShown(scope = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no matching contacts WHEN block empty THEN SendFlowWidgetShown not sent`() = runTest {
|
||||
// Arrange
|
||||
every { getContactsUseCase(query = any(), userWalletId = null) } returns flowOf(emptyList())
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the widget is hidden, so nothing is reported.
|
||||
assertThat(model.state.value).isEqualTo(ContactsBlockUM.Hidden)
|
||||
verify(exactly = 0) { analyticsSender.sendSendFlowWidgetShown(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN populated block WHEN contact tapped THEN ContactSelectedInSend sent AND click propagated`() = runTest {
|
||||
// Arrange
|
||||
var clicked: MatchedContact? = null
|
||||
every { getContactsUseCase(query = any(), userWalletId = null) } returns flowOf(listOf(contact(id = "42")))
|
||||
val model = createModel(testScope = this, onContactClick = { clicked = it })
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
(model.state.value as ContactsBlockUM.Content).contacts.first().onClick()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
|
||||
assertThat(clicked?.contactId).isEqualTo("42")
|
||||
}
|
||||
|
||||
private fun contact(id: String): Contact = Contact(
|
||||
id = ContactId(id),
|
||||
walletId = UserWalletId("a"),
|
||||
name = ContactName("Contact $id").getOrNull()!!,
|
||||
icon = "",
|
||||
iconColor = CryptoPortfolioIcon.Color.Azure.name,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("e-$id"),
|
||||
address = "0x$id",
|
||||
networkId = Network.RawID(ETHEREUM),
|
||||
networkName = "Ethereum",
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createModel(
|
||||
testScope: TestScope,
|
||||
onContactClick: (MatchedContact) -> Unit = {},
|
||||
onSeeAllClick: () -> Unit = {},
|
||||
): ContactsBlockModel {
|
||||
val params = AddressBookContactsBlockComponent.Params(
|
||||
network = network,
|
||||
queryFlow = MutableStateFlow(""),
|
||||
onContactClick = onContactClick,
|
||||
onSeeAllClick = onSeeAllClick,
|
||||
)
|
||||
return ContactsBlockModel(
|
||||
paramsContainer = MutableParamsContainer(value = params),
|
||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
stateController = ContactsBlockStateController(),
|
||||
analyticsSender = analyticsSender,
|
||||
getContactsUseCase = getContactsUseCase,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ETHEREUM = "ethereum"
|
||||
const val TIMESTAMP = "2026-06-10T14:30:00.000Z"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
|
|
@ -13,6 +12,7 @@ import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListSc
|
|||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactSaved
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -37,6 +37,7 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
private val sender = AddressBookAnalyticsSender(
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
dispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -50,13 +51,14 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
@ProvideTestModels
|
||||
fun sendContactListScreenOpened(model: ScreenOpenedModel) = runTest {
|
||||
// Act
|
||||
sender.sendContactListScreenOpened(mode = model.mode, scope = this)
|
||||
sender.sendContactListScreenOpened(source = model.source, contactsCount = model.contactsCount, scope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.ContactListScreenOpened(
|
||||
walletId = EXPECTED_WALLET_ID,
|
||||
source = model.expectedSource,
|
||||
source = model.source,
|
||||
contactsCount = model.contactsCount,
|
||||
)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
|
@ -69,7 +71,7 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.AddContactTapped(
|
||||
val expected = AddContactTapped(
|
||||
walletId = EXPECTED_WALLET_ID,
|
||||
source = model.expectedSource,
|
||||
)
|
||||
|
|
@ -83,7 +85,7 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
sender.sendContactSaved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, isEdit = model.isEdit)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.ContactSaved(
|
||||
val expected = ContactSaved(
|
||||
walletId = EXPECTED_WALLET_ID,
|
||||
contactId = CONTACT_ID,
|
||||
mode = model.expectedMode,
|
||||
|
|
@ -129,7 +131,92 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
verify(exactly = 1) { analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened) }
|
||||
}
|
||||
|
||||
internal data class ScreenOpenedModel(val mode: AddressBookOpenMode, val expectedSource: Source)
|
||||
@Test
|
||||
fun `WHEN sendContactScreenOpened THEN event sent with selected wallet`() = runTest {
|
||||
// Act
|
||||
sender.sendContactScreenOpened(contactId = CONTACT_ID, scope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.ContactScreenOpened(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN sendSendFlowWidgetShown THEN event sent with selected wallet`() = runTest {
|
||||
// Act
|
||||
sender.sendSendFlowWidgetShown(scope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.SendFlowWidgetShown(walletId = EXPECTED_WALLET_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN sendContactSelectedInSend THEN event sent with selected wallet`() = runTest {
|
||||
// Act
|
||||
sender.sendContactSelectedInSend(contactId = CONTACT_ID, scope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.ContactSelectedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onAddressSubstitutedInSend THEN event sent`() {
|
||||
// Act
|
||||
sender.onAddressSubstitutedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.AddressSubstitutedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideContactIdModels")
|
||||
fun sendAddressInvalid(contactId: String) {
|
||||
// Act
|
||||
sender.sendAddressInvalid(walletId = EXPECTED_WALLET_ID, contactId = contactId)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.AddressInvalid(walletId = EXPECTED_WALLET_ID, contactId = contactId)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideNullableContactIdModels")
|
||||
fun sendDuplicateNameErrorShown(contactId: String?) {
|
||||
// Act
|
||||
sender.sendDuplicateNameErrorShown(walletId = EXPECTED_WALLET_ID, contactId = contactId)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.DuplicateNameErrorShown(walletId = EXPECTED_WALLET_ID, contactId = contactId)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN sendAddressRemoved THEN event sent`() {
|
||||
// Act
|
||||
sender.sendAddressRemoved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.AddressRemoved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN sendContactDeleted THEN event sent`() {
|
||||
// Act
|
||||
sender.sendContactDeleted(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
|
||||
// Assert
|
||||
val expected = AddressBookEvents.ContactDeleted(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
|
||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||
}
|
||||
|
||||
internal data class ScreenOpenedModel(val source: Source, val contactsCount: Int)
|
||||
|
||||
internal data class AddContactModel(val fromSendSuccess: Boolean, val expectedSource: AddContactTapped.Source)
|
||||
|
||||
|
|
@ -138,15 +225,8 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
internal data class SaveErrorModel(val error: SaveContactError, val expectedType: ErrorType?)
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ScreenOpenedModel(mode = AddressBookOpenMode.Default, expectedSource = Source.Settings),
|
||||
ScreenOpenedModel(
|
||||
mode = AddressBookOpenMode.ContactSelection(networkId = "ethereum"),
|
||||
expectedSource = Source.SendFlow,
|
||||
),
|
||||
ScreenOpenedModel(
|
||||
mode = AddressBookOpenMode.WithContactCreation(address = "0xABC", networkId = "ethereum"),
|
||||
expectedSource = Source.SendFlow,
|
||||
),
|
||||
ScreenOpenedModel(source = Source.Settings, contactsCount = 0),
|
||||
ScreenOpenedModel(source = Source.SendFlow, contactsCount = 3),
|
||||
)
|
||||
|
||||
private fun provideAddContactModels() = listOf(
|
||||
|
|
@ -174,6 +254,12 @@ internal class AddressBookAnalyticsSenderTest {
|
|||
SaveErrorModel(error = SaveContactError.Name(mockk()), expectedType = null),
|
||||
)
|
||||
|
||||
// Create sends an empty contact id, edit sends the contact id.
|
||||
private fun provideContactIdModels() = listOf("", CONTACT_ID)
|
||||
|
||||
// Duplicate-name allows a null contact id (create) as well as an edited contact's id.
|
||||
private fun provideNullableContactIdModels() = listOf(null, CONTACT_ID)
|
||||
|
||||
private companion object {
|
||||
val EXPECTED_WALLET_ID = UserWalletId("0011223344")
|
||||
const val CONTACT_ID = "contact-42"
|
||||
|
|
|
|||
|
|
@ -130,34 +130,9 @@ internal class EditContactModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN new contact without predefined address WHEN created THEN AddContactTapped sent from settings`() =
|
||||
runTest {
|
||||
// Act
|
||||
createModel(testScope = this, params = createParams(contactId = null, predefinedAddress = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN new contact with predefined address WHEN created THEN AddContactTapped sent from send success`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
|
||||
|
||||
// Act
|
||||
createModel(testScope = this, params = createParams(predefinedAddress = predefined))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contactId WHEN model created THEN AddContactTapped not sent`() = runTest {
|
||||
// Act
|
||||
createModel(testScope = this, params = createParams(contactId = ContactId(value = "contact-id")))
|
||||
fun `GIVEN editor opened WHEN model created THEN AddContactTapped not sent from editor`() = runTest {
|
||||
// The Add-Contact-Tapped funnel event belongs to the "Add contact" button handlers, not the editor screen.
|
||||
createModel(testScope = this, params = createParams(contactId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
|
|
@ -283,7 +258,6 @@ internal class EditContactModelTest {
|
|||
// Assert
|
||||
assertThat(addClicked).isTrue()
|
||||
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
|
||||
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -311,7 +285,6 @@ internal class EditContactModelTest {
|
|||
assertThat(model.state.value.isAddAddressEnabled).isFalse()
|
||||
assertThat(addClicked).isFalse()
|
||||
verify { messageSender.send(any<DialogMessage>()) }
|
||||
verify(exactly = 0) { analyticsSender.sendAddressScreenOpened() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -883,6 +856,153 @@ internal class EditContactModelTest {
|
|||
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN new contact WHEN created THEN ContactScreenOpened sent with empty contactId`() = runTest {
|
||||
// Act
|
||||
createModel(testScope = this, params = createParams(contactId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendContactScreenOpened(contactId = "", scope = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contact WHEN created THEN ContactScreenOpened sent with contactId`() = runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
every { getContactByIdUseCase(ContactId("c-1")) } returns
|
||||
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
|
||||
|
||||
// Act
|
||||
createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendContactScreenOpened(contactId = "c-1", scope = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN duplicate name in selected wallet WHEN name entered THEN DuplicateNameErrorShown sent`() = runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
coEvery { contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left()
|
||||
val model = createModel(testScope = this, params = createParams(contactId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onNameChange("Satoshi")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — create mode reports a null contact id.
|
||||
verify(exactly = 1) {
|
||||
analyticsSender.sendDuplicateNameErrorShown(walletId = walletA.walletId, contactId = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unique name WHEN name entered THEN DuplicateNameErrorShown not sent`() = runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onNameChange("Satoshi")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) { analyticsSender.sendDuplicateNameErrorShown(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN several addresses WHEN one is deleted THEN AddressRemoved sent AND ContactDeleted not sent`() = runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(twoAddressContact())
|
||||
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.createAddressInfoParams("0xAAA").onDeleteAddress()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendAddressRemoved(walletId = walletA.walletId, contactId = "c-1") }
|
||||
verify(exactly = 0) { analyticsSender.sendContactDeleted(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN last address WHEN deleted AND confirmed THEN AddressRemoved then ContactDeleted sent`() = runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
every { getContactByIdUseCase(ContactId("c-1")) } returns
|
||||
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
|
||||
coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right()
|
||||
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act — deleting the only address prompts a contact deletion; confirming performs it.
|
||||
model.createAddressInfoParams("0xABC").onDeleteAddress()
|
||||
val dialog = slot<DialogMessage>()
|
||||
verify { messageSender.send(capture(dialog)) }
|
||||
dialog.captured.firstAction.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — both events fire: the address removal, then the backend-confirmed deletion.
|
||||
verify(exactly = 1) { analyticsSender.sendAddressRemoved(walletId = walletA.walletId, contactId = "c-1") }
|
||||
verify(exactly = 1) { analyticsSender.sendContactDeleted(walletId = walletA.walletId, contactId = "c-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contact WHEN explicitly deleted THEN ContactDeleted sent AND AddressRemoved not sent`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
every { getContactByIdUseCase(ContactId("c-1")) } returns
|
||||
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
|
||||
coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right()
|
||||
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onDeleteClick?.invoke()
|
||||
val dialog = slot<DialogMessage>()
|
||||
verify { messageSender.send(capture(dialog)) }
|
||||
dialog.captured.firstAction.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — explicit deletion is not an address removal.
|
||||
verify(exactly = 1) { analyticsSender.sendContactDeleted(walletId = walletA.walletId, contactId = "c-1") }
|
||||
verify(exactly = 0) { analyticsSender.sendAddressRemoved(any(), any()) }
|
||||
}
|
||||
|
||||
private fun twoAddressContact(): Contact = existingContact(walletId = "aa", name = "Alice", address = "0xAAA").copy(
|
||||
addresses = 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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun existingContact(walletId: String, name: String, address: String): Contact = Contact(
|
||||
id = ContactId("c-1"),
|
||||
walletId = UserWalletId(walletId),
|
||||
|
|
|
|||
|
|
@ -4,18 +4,15 @@ import com.google.common.truth.Truth.assertThat
|
|||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
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.VerifiedContact
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
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.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
|
|
@ -25,6 +22,7 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
|||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -32,7 +30,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.*
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
|
|
@ -40,6 +41,7 @@ internal class AddressBookListModelTest {
|
|||
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true)
|
||||
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ internal class AddressBookListModelTest {
|
|||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase)
|
||||
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender, contactSelectionTrigger)
|
||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||
flowOf(linkedMapOf<UserWalletId, UserWallet>())
|
||||
}
|
||||
|
|
@ -119,6 +121,57 @@ internal class AddressBookListModelTest {
|
|||
assertThat(clickedId).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN default mode WHEN created THEN ContactListScreenOpened sent with settings source and all-tab count`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
|
||||
|
||||
// Act
|
||||
createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — count comes from the list's own contacts subscription.
|
||||
verify(exactly = 1) {
|
||||
analyticsSender.sendContactListScreenOpened(source = Source.Settings, contactsCount = 2, scope = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN selector mode WHEN created THEN ContactListScreenOpened sent with send_flow source`() = runTest {
|
||||
// Arrange
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(emptyList())
|
||||
|
||||
// Act
|
||||
createModel(testScope = this, mode = AddressBookRoute.ListMode.Selector(networkId = "ethereum"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the "See all" list opened from Send reports send_flow, even with zero contacts.
|
||||
verify(exactly = 1) {
|
||||
analyticsSender.sendContactListScreenOpened(source = Source.SendFlow, contactsCount = 0, scope = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN selector mode WHEN contact picked THEN ContactSelectedInSend sent`() = runTest {
|
||||
// Arrange — a contact with a single ethereum address matches the selection network.
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
|
||||
val model = createModel(
|
||||
testScope = this,
|
||||
mode = AddressBookRoute.ListMode.Selector(networkId = "ethereum"),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act — tapping a contact in selector mode picks it.
|
||||
(model.state.value as AddressBookListUM.Content).contacts.first().onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
|
||||
}
|
||||
|
||||
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact(
|
||||
contact = Contact(
|
||||
id = ContactId(id),
|
||||
|
|
@ -159,6 +212,7 @@ internal class AddressBookListModelTest {
|
|||
stateController = AddressBookListStateController(),
|
||||
router = router,
|
||||
contactSelectionTrigger = contactSelectionTrigger,
|
||||
analyticsSender = analyticsSender,
|
||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue