Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 14:17:20 +01:00
commit 15a518c576
19 changed files with 93 additions and 171 deletions

View file

@ -42,7 +42,6 @@ internal class ContactsBlockModel @Inject constructor(
combine(
params.queryFlow.flatMapLatest { query ->
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
.map { verified -> verified.map { it.contact } }
},
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { contacts, wallets -> contacts to wallets.values.toList() }

View file

@ -8,7 +8,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -63,7 +63,7 @@ internal class AddressBookListModel @Inject constructor(
private val searchActive = MutableStateFlow(value = false)
private val selectedWalletId = MutableStateFlow<String?>(value = null)
private val allContacts: SharedFlow<List<VerifiedContact>> =
private val allContacts: SharedFlow<List<Contact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
@ -179,8 +179,8 @@ internal class AddressBookListModel @Inject constructor(
}
private data class ListInputs(
val allContacts: List<VerifiedContact>,
val matchedContacts: List<VerifiedContact>,
val allContacts: List<Contact>,
val matchedContacts: List<Contact>,
val query: String,
val selectedWalletId: String?,
val wallets: Map<UserWalletId, UserWallet>,

View file

@ -4,7 +4,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.MatchedContact
@ -23,8 +23,8 @@ import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class UpdateAddressBookListContentTransformer(
wallets: Map<UserWalletId, UserWallet>,
private val allContacts: List<VerifiedContact>,
private val matchedContacts: List<VerifiedContact>,
private val allContacts: List<Contact>,
private val matchedContacts: List<Contact>,
private val mode: AddressBookRoute.ListMode,
private val selectedWalletId: String?,
private val query: String,
@ -73,14 +73,14 @@ internal class UpdateAddressBookListContentTransformer(
DefaultContactConverter(onContactClick).convertList(matchedContacts)
is AddressBookRoute.ListMode.Selector ->
SelectorContactConverter(onPickContact)
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId))
.convertList(ContactMatcher.match(matchedContacts, mode.networkId))
}
/** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */
private fun totalWalletIds(): Set<String> = when (val mode = mode) {
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue }
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.walletId.stringValue }
is AddressBookRoute.ListMode.Selector ->
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
ContactMatcher.match(allContacts, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
}
private fun contentMode(): ContentMode = when (mode) {

View file

@ -1,30 +1,29 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class DefaultContactConverter(
private val onContactClick: (String) -> Unit,
) : Converter<VerifiedContact, ContactUM> {
) : Converter<Contact, ContactUM> {
override fun convert(value: VerifiedContact): ContactUM {
val contact = value.contact
val name = contact.name.value
override fun convert(value: Contact): ContactUM {
val name = value.name.value
return ContactUM(
id = contact.id.value,
walletId = contact.walletId.stringValue,
id = value.id.value,
walletId = value.walletId.stringValue,
name = name,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon }
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == value.icon }
?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == value.iconColor }
?: CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = contact.addresses.size,
onClick = { onContactClick(contact.id.value) },
networkAddressCount = value.addresses.size,
onClick = { onContactClick(value.id.value) },
)
}
}

View file

@ -7,7 +7,6 @@ import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
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
@ -48,7 +47,7 @@ internal class ContactsBlockModelTest {
fun resetMocks() {
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender)
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
flowOf(linkedMapOf<UserWalletId, UserWallet>())
flowOf(linkedMapOf())
}
@AfterEach
@ -61,7 +60,7 @@ internal class ContactsBlockModelTest {
fun `GIVEN matching contacts WHEN block populated THEN SendFlowWidgetShown sent once`() = runTest {
// Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns
flowOf(listOf(verified(id = "1"), verified(id = "2")))
flowOf(listOf(contact(id = "1"), contact(id = "2")))
// Act
createModel(testScope = this)
@ -91,7 +90,7 @@ internal class ContactsBlockModelTest {
// Arrange
var clicked: MatchedContact? = null
every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns
flowOf(listOf(verified(id = "42")))
flowOf(listOf(contact(id = "42")))
val model = createModel(testScope = this, onContactClick = { clicked = it })
advanceUntilIdle()
@ -103,9 +102,6 @@ internal class ContactsBlockModelTest {
assertThat(clicked?.contactId).isEqualTo("42")
}
private fun verified(id: String): VerifiedContact =
VerifiedContact(contact = contact(id = id), invalidEntries = emptyList())
private fun contact(id: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("a"),

View file

@ -88,7 +88,7 @@ internal class AddressBookListModelTest {
val syncGate = CompletableDeferred<Unit>()
coEvery { syncAddressBooksUseCase() } coAnswers { syncGate.await(); Unit.right() }
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice")))
flowOf(listOf(contact(id = "1", name = "Alice")))
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
@ -109,7 +109,7 @@ internal class AddressBookListModelTest {
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
// Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
flowOf(listOf(contact(id = "1", name = "Alice"), contact(id = "2", name = "Bob")))
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
@ -139,7 +139,7 @@ internal class AddressBookListModelTest {
// Arrange
var clickedId: String? = null
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
flowOf(listOf(contact(id = "42", name = "Alice")))
val model = createModel(
testScope = this,
mode = AddressBookRoute.ListMode.Default,
@ -159,7 +159,7 @@ internal class AddressBookListModelTest {
runTest {
// Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
flowOf(listOf(contact(id = "1", name = "Alice"), contact(id = "2", name = "Bob")))
// Act
createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
@ -190,7 +190,7 @@ internal class AddressBookListModelTest {
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")))
flowOf(listOf(contact(id = "42", name = "Alice")))
val model = createModel(
testScope = this,
mode = AddressBookRoute.ListMode.Selector(networkId = "ethereum"),
@ -205,26 +205,23 @@ internal class AddressBookListModelTest {
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
}
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact(
contact = Contact(
id = ContactId(id),
walletId = UserWalletId("a"),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = CryptoPortfolioIcon.Color.Azure.name,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addresses = listOf(
AddressEntry(
id = AddressEntryId("e-$id"),
address = "0xABC",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
private fun contact(id: String, name: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("a"),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = CryptoPortfolioIcon.Color.Azure.name,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addresses = listOf(
AddressEntry(
id = AddressEntryId("e-$id"),
address = "0xABC",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
),
invalidEntries = emptyList(),
)
private fun createModel(

View file

@ -23,7 +23,7 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob"))
val all = listOf(contact(wallet1, "Alice"), contact(wallet1, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
@ -40,7 +40,7 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN contacts in two wallets WHEN blank query THEN All plus per-wallet chips with All selected`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
val all = listOf(contact(wallet1, "Alice"), contact(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
@ -58,7 +58,7 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN two wallets WHEN a wallet chip selected THEN list filtered but chips unchanged`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
val all = listOf(contact(wallet1, "Alice"), contact(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2)
@ -75,8 +75,8 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN two wallets WHEN query narrows to one wallet THEN chips kept as All plus that wallet`() {
// Arrange — the book spans two wallets, but the query matched only wallet1 in the domain
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
val matched = listOf(verified(wallet1, "Antonio"))
val all = listOf(contact(wallet1, "Antonio"), contact(wallet2, "Bob"))
val matched = listOf(contact(wallet1, "Antonio"))
// Act
val result = transform(allContacts = all, matchedContacts = matched, query = "Anto")
@ -91,8 +91,8 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN selected wallet no longer matches query THEN falls back to All`() {
// Arrange
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
val matched = listOf(verified(wallet1, "Antonio"))
val all = listOf(contact(wallet1, "Antonio"), contact(wallet2, "Bob"))
val matched = listOf(contact(wallet1, "Antonio"))
// Act — selected wallet2, but query matched only wallet1
val result = transform(
@ -120,7 +120,7 @@ internal class UpdateAddressBookListContentTransformerTest {
@Test
fun `GIVEN query matches nothing WHEN non-blank query THEN nothing found and chips hidden`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
val all = listOf(contact(wallet1, "Alice"), contact(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz")
@ -133,8 +133,8 @@ internal class UpdateAddressBookListContentTransformerTest {
}
private fun transform(
allContacts: List<VerifiedContact>,
matchedContacts: List<VerifiedContact>,
allContacts: List<Contact>,
matchedContacts: List<Contact>,
selectedWalletId: String? = null,
query: String = "",
): AddressBookListUM = UpdateAddressBookListContentTransformer(
@ -156,25 +156,22 @@ internal class UpdateAddressBookListContentTransformerTest {
private fun wallet(id: String, name: String): UserWallet =
MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name)
private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact(
contact = Contact(
id = ContactId(name + walletId),
walletId = UserWalletId(stringValue = walletId),
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
icon = "",
iconColor = "Azure",
createdAt = "2026-06-10T14:30:00.000Z",
updatedAt = "2026-06-10T14:30:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId(name),
address = "addr-$name",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
private fun contact(walletId: String, name: String): Contact = Contact(
id = ContactId(name + walletId),
walletId = UserWalletId(stringValue = walletId),
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
icon = "",
iconColor = "Azure",
createdAt = "2026-06-10T14:30:00.000Z",
updatedAt = "2026-06-10T14:30:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId(name),
address = "addr-$name",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
),
invalidEntries = emptyList(),
)
}

View file

@ -102,7 +102,6 @@ internal class SendDestinationModel @Inject constructor(
private val contacts: StateFlow<List<Contact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.map { verified -> verified.map { it.contact } }
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())

View file

@ -390,7 +390,7 @@ internal class SendDestinationModelTest {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns
flowOf(listOf(verified(buildContact(name = model.savedName, address = model.savedAddress))))
flowOf(listOf(buildContact(name = model.savedName, address = model.savedAddress)))
val sut = buildModel()
advanceUntilIdle()
@ -476,7 +476,7 @@ internal class SendDestinationModelTest {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns
flowOf(model.savedAddresses.map { verified(buildContact(address = it)) })
flowOf(model.savedAddresses.map { buildContact(address = it) })
val sut = buildBlockModel(isAddContactAvailable = model.isAddContactAvailable)
advanceUntilIdle()
@ -608,9 +608,6 @@ internal class SendDestinationModelTest {
)
}
private fun verified(contact: Contact): VerifiedContact =
VerifiedContact(contact = contact, invalidEntries = emptyList())
private fun buildContact(name: String = "Alice", address: String = "0xAddr"): Contact = Contact(
id = ContactId("c1"),
walletId = testUserWalletId,