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.UpdateAddressValidationTransformer
|
||||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
|
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
|
||||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
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.AddressMemoValidator
|
||||||
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
||||||
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
||||||
|
|
@ -46,6 +47,7 @@ internal class AddAddressModel @Inject constructor(
|
||||||
private val stateController: AddAddressStateController,
|
private val stateController: AddAddressStateController,
|
||||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||||
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
|
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
|
||||||
|
private val analyticsSender: AddressBookAnalyticsSender,
|
||||||
private val router: Router,
|
private val router: Router,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
|
|
@ -110,11 +112,13 @@ internal class AddAddressModel @Inject constructor(
|
||||||
init {
|
init {
|
||||||
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
||||||
selectNetworksResultHolder.clear()
|
selectNetworksResultHolder.clear()
|
||||||
|
sendInitAnalytics()
|
||||||
updateInitialState()
|
updateInitialState()
|
||||||
subscribeToValidation()
|
subscribeToValidation()
|
||||||
subscribeToMemoValidation()
|
subscribeToMemoValidation()
|
||||||
subscribeToSelectedNetworks()
|
subscribeToSelectedNetworks()
|
||||||
subscribeToQrScanResult()
|
subscribeToQrScanResult()
|
||||||
|
subscribeToAddressInvalid()
|
||||||
prefillData()
|
prefillData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,6 +193,22 @@ internal class AddAddressModel @Inject constructor(
|
||||||
.launchIn(modelScope)
|
.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() {
|
private fun onPaste() {
|
||||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
onAddressChange(value = clipboardManager.getText().orEmpty())
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +278,10 @@ internal class AddAddressModel @Inject constructor(
|
||||||
return matched.filter { it.toNetworkId() in selected }
|
return matched.filter { it.toNetworkId() in selected }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sendInitAnalytics() {
|
||||||
|
analyticsSender.sendAddressScreenOpened()
|
||||||
|
}
|
||||||
|
|
||||||
private data class AddressValidation(
|
private data class AddressValidation(
|
||||||
val address: String,
|
val address: String,
|
||||||
val matchedBlockchains: List<Blockchain>,
|
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 WALLET_ID = "Wallet Id"
|
||||||
private const val CONTACT_ID = "Contact Id"
|
private const val CONTACT_ID = "Contact Id"
|
||||||
private const val MODE = "Mode"
|
private const val MODE = "Mode"
|
||||||
|
private const val CONTACTS_COUNT = "Contacts Count"
|
||||||
|
|
||||||
sealed class AddressBookEvents(
|
sealed class AddressBookEvents(
|
||||||
event: String,
|
event: String,
|
||||||
|
|
@ -24,11 +25,13 @@ sealed class AddressBookEvents(
|
||||||
class ContactListScreenOpened(
|
class ContactListScreenOpened(
|
||||||
walletId: UserWalletId,
|
walletId: UserWalletId,
|
||||||
source: Source,
|
source: Source,
|
||||||
|
contactsCount: Int,
|
||||||
) : AddressBookEvents(
|
) : AddressBookEvents(
|
||||||
event = "Contact List Screen Opened",
|
event = "Contact List Screen Opened",
|
||||||
params = mapOf(
|
params = mapOf(
|
||||||
WALLET_ID to walletId.stringValue,
|
WALLET_ID to walletId.stringValue,
|
||||||
SOURCE to source.value,
|
SOURCE to source.value,
|
||||||
|
CONTACTS_COUNT to contactsCount.toString(),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
enum class Source(val value: String) {
|
enum class Source(val value: String) {
|
||||||
|
|
@ -92,11 +95,11 @@ sealed class AddressBookEvents(
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Contact editing
|
// region Contact editing
|
||||||
class ContactOpened(
|
class ContactScreenOpened(
|
||||||
walletId: UserWalletId,
|
walletId: UserWalletId,
|
||||||
contactId: String,
|
contactId: String,
|
||||||
) : AddressBookEvents(
|
) : AddressBookEvents(
|
||||||
event = "Contact Opened",
|
event = "Contact Screen Opened",
|
||||||
params = mapOf(
|
params = mapOf(
|
||||||
WALLET_ID to walletId.stringValue,
|
WALLET_ID to walletId.stringValue,
|
||||||
CONTACT_ID to contactId,
|
CONTACT_ID to contactId,
|
||||||
|
|
@ -116,7 +119,7 @@ sealed class AddressBookEvents(
|
||||||
walletId: UserWalletId,
|
walletId: UserWalletId,
|
||||||
contactId: String,
|
contactId: String,
|
||||||
) : AddressBookEvents(
|
) : AddressBookEvents(
|
||||||
event = "Contact Selected In Send",
|
event = "Contact Selected",
|
||||||
params = mapOf(
|
params = mapOf(
|
||||||
WALLET_ID to walletId.stringValue,
|
WALLET_ID to walletId.stringValue,
|
||||||
CONTACT_ID to contactId,
|
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.addressbook.usecase.GetContactsUseCase
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
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.ContactsBlockStateController
|
||||||
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
||||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
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.features.addressbook.common.ContactMatcher
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -21,12 +23,13 @@ internal class ContactsBlockModel @Inject constructor(
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
override val dispatchers: CoroutineDispatcherProvider,
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
private val stateController: ContactsBlockStateController,
|
private val stateController: ContactsBlockStateController,
|
||||||
|
private val analyticsSender: AddressBookAnalyticsSender,
|
||||||
getContactsUseCase: GetContactsUseCase,
|
getContactsUseCase: GetContactsUseCase,
|
||||||
getWalletsUseCase: GetWalletsUseCase,
|
getWalletsUseCase: GetWalletsUseCase,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
|
private var isWidgetShownReported = false
|
||||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||||
|
|
||||||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -38,17 +41,30 @@ internal class ContactsBlockModel @Inject constructor(
|
||||||
) { contacts, wallets -> contacts to wallets.values.toList() }
|
) { contacts, wallets -> contacts to wallets.values.toList() }
|
||||||
.onEach { (contacts, wallets) ->
|
.onEach { (contacts, wallets) ->
|
||||||
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
||||||
|
reportWidgetShownIfNeeded(matched.isNotEmpty())
|
||||||
stateController.update(
|
stateController.update(
|
||||||
UpdateContactsBlockStateTransformer(
|
UpdateContactsBlockStateTransformer(
|
||||||
matched = matched,
|
matched = matched,
|
||||||
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
|
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
|
||||||
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
|
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
|
||||||
onSeeAllClick = params.onSeeAllClick,
|
onSeeAllClick = params.onSeeAllClick,
|
||||||
onContactClick = params.onContactClick,
|
onContactClick = ::onContactClick,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(dispatchers.default)
|
.flowOn(dispatchers.default)
|
||||||
.launchIn(modelScope)
|
.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
|
package com.tangem.features.addressbook.common
|
||||||
|
|
||||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||||
import com.tangem.domain.addressbook.error.SaveContactError
|
import com.tangem.domain.addressbook.error.SaveContactError
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
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
|
||||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -20,21 +21,75 @@ import javax.inject.Singleton
|
||||||
internal class AddressBookAnalyticsSender @Inject constructor(
|
internal class AddressBookAnalyticsSender @Inject constructor(
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||||
private val userWalletsListRepository: UserWalletsListRepository,
|
private val userWalletsListRepository: UserWalletsListRepository,
|
||||||
) {
|
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||||
|
) : AddressBookSendAnalytics {
|
||||||
|
|
||||||
fun sendContactListScreenOpened(mode: AddressBookOpenMode, scope: CoroutineScope) {
|
fun sendContactListScreenOpened(source: Source, contactsCount: Int, scope: CoroutineScope) {
|
||||||
scope.launch {
|
scope.launch(dispatcherProvider.default) {
|
||||||
analyticsEventHandler.send(
|
analyticsEventHandler.send(
|
||||||
AddressBookEvents.ContactListScreenOpened(
|
AddressBookEvents.ContactListScreenOpened(
|
||||||
walletId = selectedWalletId(),
|
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) {
|
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
|
||||||
scope.launch {
|
scope.launch(dispatcherProvider.default) {
|
||||||
analyticsEventHandler.send(
|
analyticsEventHandler.send(
|
||||||
AddressBookEvents.AddContactTapped(
|
AddressBookEvents.AddContactTapped(
|
||||||
walletId = selectedWalletId(),
|
walletId = selectedWalletId(),
|
||||||
|
|
@ -99,11 +154,4 @@ internal class AddressBookAnalyticsSender @Inject constructor(
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.first()
|
.first()
|
||||||
.walletId
|
.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.
|
// Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
|
||||||
resultHolder.clear()
|
resultHolder.clear()
|
||||||
selectNetworksResultHolder.clear()
|
selectNetworksResultHolder.clear()
|
||||||
|
if (params.addressBookOpenMode is AddressBookOpenMode.WithContactCreation) {
|
||||||
analyticsSender.sendContactListScreenOpened(mode = params.addressBookOpenMode, scope = componentScope)
|
analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = componentScope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val clickIntents = object : AddressBookClickIntents {
|
private val clickIntents = object : AddressBookClickIntents {
|
||||||
|
|
@ -50,11 +51,14 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAddContactClick() {
|
override fun onAddContactClick() {
|
||||||
|
analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = componentScope)
|
||||||
navigation.pushNew(AddressBookRoute.EditContact())
|
navigation.pushNew(AddressBookRoute.EditContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEditContactBack() {
|
override fun onEditContactBack() {
|
||||||
navigation.pop()
|
navigation.pop { isPopped ->
|
||||||
|
if (!isPopped) router.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) {
|
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)),
|
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
|
||||||
)
|
)
|
||||||
is AddressBookOpenMode.WithContactCreation -> listOf(
|
is AddressBookOpenMode.WithContactCreation -> listOf(
|
||||||
AddressBookRoute.List(),
|
|
||||||
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
|
|
||||||
AddressBookRoute.EditContact(
|
AddressBookRoute.EditContact(
|
||||||
predefinedAddress = mode.address,
|
predefinedAddress = mode.address,
|
||||||
predefinedNetworkId = mode.networkId,
|
predefinedNetworkId = mode.networkId,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
package com.tangem.features.addressbook.di
|
package com.tangem.features.addressbook.di
|
||||||
|
|
||||||
import com.tangem.features.addressbook.AddressBookComponent
|
import com.tangem.features.addressbook.*
|
||||||
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.addressselector.DefaultAddressSelectorComponent
|
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
|
||||||
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
|
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.DefaultAddressBookComponent
|
||||||
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
|
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
|
|
@ -42,4 +39,8 @@ internal interface AddressBookComponentModule {
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
|
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.model.ParamsContainer
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
import com.tangem.core.decompose.ui.UiMessageSender
|
||||||
import com.tangem.core.ui.R
|
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.extensions.resourceReference
|
||||||
import com.tangem.core.ui.message.DialogMessage
|
import com.tangem.core.ui.message.DialogMessage
|
||||||
import com.tangem.core.ui.message.EventMessageAction
|
import com.tangem.core.ui.message.EventMessageAction
|
||||||
|
|
@ -132,12 +131,7 @@ internal class EditContactModel @Inject constructor(
|
||||||
observeNameValidation()
|
observeNameValidation()
|
||||||
observeSaveButton()
|
observeSaveButton()
|
||||||
loadExistingContact()
|
loadExistingContact()
|
||||||
sendAddContactTappedEvent()
|
sendContactScreenOpenedEvent()
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendAddContactTappedEvent() {
|
|
||||||
if (params.contactId != null) return
|
|
||||||
analyticsSender.sendAddContactTapped(fromSendSuccess = params.predefinedAddress != null, scope = modelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// region Initialization
|
// region Initialization
|
||||||
|
|
@ -231,9 +225,18 @@ internal class EditContactModel @Inject constructor(
|
||||||
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
|
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
|
||||||
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
|
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
|
||||||
) { name, walletId -> name to walletId }
|
) { 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)
|
.flowOn(dispatchers.default)
|
||||||
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
|
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,7 +293,6 @@ internal class EditContactModel @Inject constructor(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
|
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
|
||||||
analyticsSender.sendAddressScreenOpened()
|
|
||||||
params.onAddAddressClick(walletId, params.contactId?.value, null)
|
params.onAddAddressClick(walletId, params.contactId?.value, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,11 +334,6 @@ internal class EditContactModel @Inject constructor(
|
||||||
},
|
},
|
||||||
ifRight = { contact ->
|
ifRight = { contact ->
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
analyticsSender.sendContactSaved(
|
|
||||||
walletId = userWallet.walletId,
|
|
||||||
contactId = contact.id.value,
|
|
||||||
isEdit = params.contactId != null,
|
|
||||||
)
|
|
||||||
messageSender.send(
|
messageSender.send(
|
||||||
SnackbarMessage(
|
SnackbarMessage(
|
||||||
message = resourceReference(R.string.address_book_create_success_message),
|
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()
|
params.onBackClick()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -356,6 +358,10 @@ internal class EditContactModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onDeleteClick() {
|
private fun onDeleteClick() {
|
||||||
|
showDeleteContactDialog(fromLastAddressRemoval = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDeleteContactDialog(fromLastAddressRemoval: Boolean) {
|
||||||
messageSender.send(
|
messageSender.send(
|
||||||
DialogMessage(
|
DialogMessage(
|
||||||
message = resourceReference(R.string.address_book_delete_contact_description),
|
message = resourceReference(R.string.address_book_delete_contact_description),
|
||||||
|
|
@ -363,7 +369,7 @@ internal class EditContactModel @Inject constructor(
|
||||||
EventMessageAction(
|
EventMessageAction(
|
||||||
title = resourceReference(R.string.common_delete),
|
title = resourceReference(R.string.common_delete),
|
||||||
isWarning = true,
|
isWarning = true,
|
||||||
onClick = ::deleteContact,
|
onClick = { deleteContact(fromLastAddressRemoval = fromLastAddressRemoval) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -396,8 +402,12 @@ internal class EditContactModel @Inject constructor(
|
||||||
addressInfoNavigation.dismiss()
|
addressInfoNavigation.dismiss()
|
||||||
val isLastAddress = stateController.uiState.value.addresses.size <= 1
|
val isLastAddress = stateController.uiState.value.addresses.size <= 1
|
||||||
if (isLastAddress && params.contactId != null) {
|
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 {
|
} else {
|
||||||
|
contactWalletId()?.let { walletId ->
|
||||||
|
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = params.contactId?.value.orEmpty())
|
||||||
|
}
|
||||||
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +416,10 @@ internal class EditContactModel @Inject constructor(
|
||||||
|
|
||||||
// region Helpers
|
// region Helpers
|
||||||
|
|
||||||
|
private fun sendContactScreenOpenedEvent() {
|
||||||
|
analyticsSender.sendContactScreenOpened(contactId = params.contactId?.value.orEmpty(), scope = modelScope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun addAddress(address: ValidatedAddress) {
|
private fun addAddress(address: ValidatedAddress) {
|
||||||
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||||
}
|
}
|
||||||
|
|
@ -415,12 +429,12 @@ internal class EditContactModel @Inject constructor(
|
||||||
return params.contactId == null && unlockedWalletsCount > 1
|
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.isBlank()) return null
|
||||||
if (name == loadedContact.value?.name?.value) return null
|
if (name == loadedContact.value?.name?.value) return null
|
||||||
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
|
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
|
||||||
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
|
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
|
||||||
return ContactNameErrorConverter().convert(error)
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshSaveButton() {
|
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 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 {
|
modelScope.launch {
|
||||||
deleteContactUseCase(contactId).fold(
|
deleteContactUseCase(contactId).fold(
|
||||||
ifLeft = { showDeleteError() },
|
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() {
|
private fun showDiscardDialog() {
|
||||||
messageSender.send(
|
messageSender.send(
|
||||||
DialogMessage(
|
DialogMessage(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||||
import com.tangem.features.addressbook.MatchedContact
|
import com.tangem.features.addressbook.MatchedContact
|
||||||
import com.tangem.features.addressbook.SelectedContact
|
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.DefaultAddressBookListComponent
|
||||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
||||||
|
|
@ -42,6 +44,7 @@ internal class AddressBookListModel @Inject constructor(
|
||||||
private val stateController: AddressBookListStateController,
|
private val stateController: AddressBookListStateController,
|
||||||
private val router: Router,
|
private val router: Router,
|
||||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||||
|
private val analyticsSender: AddressBookAnalyticsSender,
|
||||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||||
getWalletsUseCase: GetWalletsUseCase,
|
getWalletsUseCase: GetWalletsUseCase,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
@ -87,6 +90,14 @@ internal class AddressBookListModel @Inject constructor(
|
||||||
.onEach(::updateState)
|
.onEach(::updateState)
|
||||||
.flowOn(dispatchers.default)
|
.flowOn(dispatchers.default)
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
|
|
||||||
|
sendContactListScreenOpenedEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deliverSelection(contact: SelectedContact) {
|
||||||
|
contactSelectionTrigger.trigger(contact)
|
||||||
|
selectorNavigation.dismiss()
|
||||||
|
router.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateState(inputs: ListInputs) {
|
private fun updateState(inputs: ListInputs) {
|
||||||
|
|
@ -134,6 +145,8 @@ internal class AddressBookListModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPickContact(contact: MatchedContact) {
|
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()
|
val singleEntry = contact.entries.singleOrNull()
|
||||||
if (singleEntry != null) {
|
if (singleEntry != null) {
|
||||||
deliverSelection(contact.toSelectedContact(singleEntry))
|
deliverSelection(contact.toSelectedContact(singleEntry))
|
||||||
|
|
@ -142,10 +155,22 @@ internal class AddressBookListModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deliverSelection(contact: SelectedContact) {
|
private fun sendContactListScreenOpenedEvent() {
|
||||||
contactSelectionTrigger.trigger(contact)
|
allContacts
|
||||||
selectorNavigation.dismiss()
|
.take(count = 1)
|
||||||
router.pop()
|
.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(
|
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.clipboard.ClipboardManager
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
|
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.models.SourceType
|
||||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
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.AddressMemoValidator
|
||||||
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
||||||
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
||||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
import io.mockk.clearMocks
|
import io.mockk.*
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import io.mockk.verify
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
@ -45,6 +43,7 @@ internal class AddAddressModelTest {
|
||||||
private val clipboardManager: ClipboardManager = mockk()
|
private val clipboardManager: ClipboardManager = mockk()
|
||||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
|
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
|
||||||
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase = mockk()
|
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase = mockk()
|
||||||
|
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||||
private val router: Router = mockk(relaxed = true)
|
private val router: Router = mockk(relaxed = true)
|
||||||
private val selectNetworksResultHolder = SelectNetworksResultHolder()
|
private val selectNetworksResultHolder = SelectNetworksResultHolder()
|
||||||
|
|
||||||
|
|
@ -58,6 +57,7 @@ internal class AddAddressModelTest {
|
||||||
clipboardManager,
|
clipboardManager,
|
||||||
listenToQrScanningUseCase,
|
listenToQrScanningUseCase,
|
||||||
checkAddressDuplicateUseCase,
|
checkAddressDuplicateUseCase,
|
||||||
|
analyticsSender,
|
||||||
router,
|
router,
|
||||||
)
|
)
|
||||||
selectNetworksResultHolder.clear()
|
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(
|
private fun params(
|
||||||
walletId: String? = null,
|
walletId: String? = null,
|
||||||
excludeContactId: String? = null,
|
excludeContactId: String? = null,
|
||||||
|
|
@ -663,6 +738,7 @@ internal class AddAddressModelTest {
|
||||||
stateController = AddAddressStateController(),
|
stateController = AddAddressStateController(),
|
||||||
selectNetworksResultHolder = selectNetworksResultHolder,
|
selectNetworksResultHolder = selectNetworksResultHolder,
|
||||||
checkAddressDuplicateUseCase = checkAddressDuplicateUseCase,
|
checkAddressDuplicateUseCase = checkAddressDuplicateUseCase,
|
||||||
|
analyticsSender = analyticsSender,
|
||||||
router = router,
|
router = router,
|
||||||
).also { model = it }
|
).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
|
package com.tangem.features.addressbook.common
|
||||||
|
|
||||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||||
import com.tangem.domain.addressbook.error.SaveContactError
|
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.ContactSaved
|
||||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
||||||
import com.tangem.test.core.ProvideTestModels
|
import com.tangem.test.core.ProvideTestModels
|
||||||
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
|
@ -37,6 +37,7 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
private val sender = AddressBookAnalyticsSender(
|
private val sender = AddressBookAnalyticsSender(
|
||||||
analyticsEventHandler = analyticsEventHandler,
|
analyticsEventHandler = analyticsEventHandler,
|
||||||
userWalletsListRepository = userWalletsListRepository,
|
userWalletsListRepository = userWalletsListRepository,
|
||||||
|
dispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
|
@ -50,13 +51,14 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
@ProvideTestModels
|
@ProvideTestModels
|
||||||
fun sendContactListScreenOpened(model: ScreenOpenedModel) = runTest {
|
fun sendContactListScreenOpened(model: ScreenOpenedModel) = runTest {
|
||||||
// Act
|
// Act
|
||||||
sender.sendContactListScreenOpened(mode = model.mode, scope = this)
|
sender.sendContactListScreenOpened(source = model.source, contactsCount = model.contactsCount, scope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
val expected = AddressBookEvents.ContactListScreenOpened(
|
val expected = AddressBookEvents.ContactListScreenOpened(
|
||||||
walletId = EXPECTED_WALLET_ID,
|
walletId = EXPECTED_WALLET_ID,
|
||||||
source = model.expectedSource,
|
source = model.source,
|
||||||
|
contactsCount = model.contactsCount,
|
||||||
)
|
)
|
||||||
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
verify(exactly = 1) { analyticsEventHandler.send(expected) }
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +71,7 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
val expected = AddressBookEvents.AddContactTapped(
|
val expected = AddContactTapped(
|
||||||
walletId = EXPECTED_WALLET_ID,
|
walletId = EXPECTED_WALLET_ID,
|
||||||
source = model.expectedSource,
|
source = model.expectedSource,
|
||||||
)
|
)
|
||||||
|
|
@ -83,7 +85,7 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
sender.sendContactSaved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, isEdit = model.isEdit)
|
sender.sendContactSaved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, isEdit = model.isEdit)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
val expected = AddressBookEvents.ContactSaved(
|
val expected = ContactSaved(
|
||||||
walletId = EXPECTED_WALLET_ID,
|
walletId = EXPECTED_WALLET_ID,
|
||||||
contactId = CONTACT_ID,
|
contactId = CONTACT_ID,
|
||||||
mode = model.expectedMode,
|
mode = model.expectedMode,
|
||||||
|
|
@ -129,7 +131,92 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
verify(exactly = 1) { analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened) }
|
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)
|
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?)
|
internal data class SaveErrorModel(val error: SaveContactError, val expectedType: ErrorType?)
|
||||||
|
|
||||||
private fun provideTestModels() = listOf(
|
private fun provideTestModels() = listOf(
|
||||||
ScreenOpenedModel(mode = AddressBookOpenMode.Default, expectedSource = Source.Settings),
|
ScreenOpenedModel(source = Source.Settings, contactsCount = 0),
|
||||||
ScreenOpenedModel(
|
ScreenOpenedModel(source = Source.SendFlow, contactsCount = 3),
|
||||||
mode = AddressBookOpenMode.ContactSelection(networkId = "ethereum"),
|
|
||||||
expectedSource = Source.SendFlow,
|
|
||||||
),
|
|
||||||
ScreenOpenedModel(
|
|
||||||
mode = AddressBookOpenMode.WithContactCreation(address = "0xABC", networkId = "ethereum"),
|
|
||||||
expectedSource = Source.SendFlow,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun provideAddContactModels() = listOf(
|
private fun provideAddContactModels() = listOf(
|
||||||
|
|
@ -174,6 +254,12 @@ internal class AddressBookAnalyticsSenderTest {
|
||||||
SaveErrorModel(error = SaveContactError.Name(mockk()), expectedType = null),
|
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 {
|
private companion object {
|
||||||
val EXPECTED_WALLET_ID = UserWalletId("0011223344")
|
val EXPECTED_WALLET_ID = UserWalletId("0011223344")
|
||||||
const val CONTACT_ID = "contact-42"
|
const val CONTACT_ID = "contact-42"
|
||||||
|
|
|
||||||
|
|
@ -130,34 +130,9 @@ internal class EditContactModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN new contact without predefined address WHEN created THEN AddContactTapped sent from settings`() =
|
fun `GIVEN editor opened WHEN model created THEN AddContactTapped not sent from editor`() = runTest {
|
||||||
runTest {
|
// The Add-Contact-Tapped funnel event belongs to the "Add contact" button handlers, not the editor screen.
|
||||||
// Act
|
createModel(testScope = this, params = createParams(contactId = null))
|
||||||
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")))
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -283,7 +258,6 @@ internal class EditContactModelTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(addClicked).isTrue()
|
assertThat(addClicked).isTrue()
|
||||||
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
|
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
|
||||||
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -311,7 +285,6 @@ internal class EditContactModelTest {
|
||||||
assertThat(model.state.value.isAddAddressEnabled).isFalse()
|
assertThat(model.state.value.isAddAddressEnabled).isFalse()
|
||||||
assertThat(addClicked).isFalse()
|
assertThat(addClicked).isFalse()
|
||||||
verify { messageSender.send(any<DialogMessage>()) }
|
verify { messageSender.send(any<DialogMessage>()) }
|
||||||
verify(exactly = 0) { analyticsSender.sendAddressScreenOpened() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -883,6 +856,153 @@ internal class EditContactModelTest {
|
||||||
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
|
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(
|
private fun existingContact(walletId: String, name: String, address: String): Contact = Contact(
|
||||||
id = ContactId("c-1"),
|
id = ContactId("c-1"),
|
||||||
walletId = UserWalletId(walletId),
|
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.model.MutableParamsContainer
|
||||||
import com.tangem.core.decompose.navigation.Router
|
import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||||
import com.tangem.domain.addressbook.model.AddressEntry
|
import com.tangem.domain.addressbook.model.*
|
||||||
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.models.account.CryptoPortfolioIcon
|
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
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.DefaultAddressBookListComponent
|
||||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
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.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
@ -32,7 +30,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
|
@ -40,6 +41,7 @@ internal class AddressBookListModelTest {
|
||||||
|
|
||||||
private val router: Router = mockk(relaxed = true)
|
private val router: Router = mockk(relaxed = true)
|
||||||
private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true)
|
private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true)
|
||||||
|
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
||||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||||
|
|
||||||
|
|
@ -47,7 +49,7 @@ internal class AddressBookListModelTest {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun resetMocks() {
|
fun resetMocks() {
|
||||||
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase)
|
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender, contactSelectionTrigger)
|
||||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||||
flowOf(linkedMapOf<UserWalletId, UserWallet>())
|
flowOf(linkedMapOf<UserWalletId, UserWallet>())
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +121,57 @@ internal class AddressBookListModelTest {
|
||||||
assertThat(clickedId).isEqualTo("42")
|
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(
|
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact(
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
id = ContactId(id),
|
id = ContactId(id),
|
||||||
|
|
@ -159,6 +212,7 @@ internal class AddressBookListModelTest {
|
||||||
stateController = AddressBookListStateController(),
|
stateController = AddressBookListStateController(),
|
||||||
router = router,
|
router = router,
|
||||||
contactSelectionTrigger = contactSelectionTrigger,
|
contactSelectionTrigger = contactSelectionTrigger,
|
||||||
|
analyticsSender = analyticsSender,
|
||||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||||
getWalletsUseCase = getWalletsUseCase,
|
getWalletsUseCase = getWalletsUseCase,
|
||||||
).also { model = it }
|
).also { model = it }
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
|
||||||
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
|
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
|
||||||
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
|
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
|
import com.tangem.features.addressbook.AddressBookSendAnalytics
|
||||||
import com.tangem.features.addressbook.ContactSelectionListener
|
import com.tangem.features.addressbook.ContactSelectionListener
|
||||||
import com.tangem.features.addressbook.MatchedContact
|
import com.tangem.features.addressbook.MatchedContact
|
||||||
import com.tangem.features.addressbook.SelectedContact
|
import com.tangem.features.addressbook.SelectedContact
|
||||||
|
|
@ -86,6 +87,7 @@ internal class SendDestinationModel @Inject constructor(
|
||||||
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
|
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
|
||||||
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
|
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
|
||||||
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
|
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
|
||||||
|
private val addressBookSendAnalytics: AddressBookSendAnalytics,
|
||||||
getContactsUseCase: GetContactsUseCase,
|
getContactsUseCase: GetContactsUseCase,
|
||||||
contactSelectionListener: ContactSelectionListener,
|
contactSelectionListener: ContactSelectionListener,
|
||||||
) : Model(), SendDestinationClickIntents {
|
) : Model(), SendDestinationClickIntents {
|
||||||
|
|
@ -182,6 +184,7 @@ internal class SendDestinationModel @Inject constructor(
|
||||||
|
|
||||||
fun applySelectedContact(contact: SelectedContact) {
|
fun applySelectedContact(contact: SelectedContact) {
|
||||||
addressSelectorNavigation.dismiss()
|
addressSelectorNavigation.dismiss()
|
||||||
|
addressBookSendAnalytics.onAddressSubstitutedInSend(walletId = userWalletId, contactId = contact.contactId)
|
||||||
_uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true))
|
_uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true))
|
||||||
_uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon))
|
_uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.tangem.features.send.subcomponents.destination.model
|
package com.tangem.features.send.subcomponents.destination.model
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import arrow.core.left
|
import arrow.core.left
|
||||||
import arrow.core.right
|
import arrow.core.right
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
|
@ -30,6 +33,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
|
||||||
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
|
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
|
||||||
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
|
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
|
import com.tangem.features.addressbook.AddressBookSendAnalytics
|
||||||
import com.tangem.features.addressbook.ContactSelectionListener
|
import com.tangem.features.addressbook.ContactSelectionListener
|
||||||
import com.tangem.features.addressbook.MatchedContact
|
import com.tangem.features.addressbook.MatchedContact
|
||||||
import com.tangem.features.addressbook.SelectedContact
|
import com.tangem.features.addressbook.SelectedContact
|
||||||
|
|
@ -38,6 +42,7 @@ import com.tangem.features.send.api.entity.PredefinedValues
|
||||||
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
|
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
|
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
|
||||||
|
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
|
||||||
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
|
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
|
||||||
import com.tangem.features.send.common.CommonSendRoute
|
import com.tangem.features.send.common.CommonSendRoute
|
||||||
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
|
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
|
||||||
|
|
@ -46,10 +51,6 @@ import com.tangem.features.send.subcomponents.destination.analytics.SendDestinat
|
||||||
import com.tangem.features.send.testDispatcherProvider
|
import com.tangem.features.send.testDispatcherProvider
|
||||||
import com.tangem.test.core.ProvideTestModels
|
import com.tangem.test.core.ProvideTestModels
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -93,6 +94,7 @@ internal class SendDestinationModelTest {
|
||||||
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
||||||
private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true)
|
private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true)
|
||||||
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
|
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
|
||||||
|
private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true)
|
||||||
private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true)
|
private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true)
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
|
@ -320,6 +322,25 @@ internal class SendDestinationModelTest {
|
||||||
assertThat(content(sut).addressTextField.contactName).isEqualTo("Bob")
|
assertThat(content(sut).addressTextField.contactName).isEqualTo("Bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a selected contact WHEN applySelectedContact THEN AddressSubstitutedInSend reported`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
coEvery {
|
||||||
|
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
|
||||||
|
} returns AddressValidation.Success.Valid.right()
|
||||||
|
val sut = buildModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
sut.applySelectedContact(selectedContact(name = "Bob", address = "0xBob"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
verify(exactly = 1) {
|
||||||
|
addressBookSendAnalytics.onAddressSubstitutedInSend(walletId = testUserWalletId, contactId = "c1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() =
|
fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
@ -331,8 +352,8 @@ internal class SendDestinationModelTest {
|
||||||
addressTextField = DestinationTextFieldUM.RecipientAddress(
|
addressTextField = DestinationTextFieldUM.RecipientAddress(
|
||||||
value = "0xDave",
|
value = "0xDave",
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
|
||||||
placeholder = com.tangem.core.ui.extensions.stringReference(""),
|
placeholder = stringReference(""),
|
||||||
label = com.tangem.core.ui.extensions.stringReference(""),
|
label = stringReference(""),
|
||||||
isValuePasted = false,
|
isValuePasted = false,
|
||||||
contactName = "Dave",
|
contactName = "Dave",
|
||||||
),
|
),
|
||||||
|
|
@ -549,6 +570,7 @@ internal class SendDestinationModelTest {
|
||||||
getBackupProblematicWalletForAddressUseCase = getBackupProblematicWalletForAddressUseCase,
|
getBackupProblematicWalletForAddressUseCase = getBackupProblematicWalletForAddressUseCase,
|
||||||
sendDestinationAlertFactory = sendDestinationAlertFactory,
|
sendDestinationAlertFactory = sendDestinationAlertFactory,
|
||||||
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
|
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
|
||||||
|
addressBookSendAnalytics = addressBookSendAnalytics,
|
||||||
getContactsUseCase = getContactsUseCase,
|
getContactsUseCase = getContactsUseCase,
|
||||||
contactSelectionListener = contactSelectionListener,
|
contactSelectionListener = contactSelectionListener,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue