Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 15:42:03 +02:00
parent 277662b1ba
commit 0c4d14650d
25 changed files with 829 additions and 213 deletions

View file

@ -3,7 +3,6 @@ package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.StateFlow
/**
@ -16,7 +15,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookContactsBlockComponent>
/**
* @property userWalletId the sending wallet whose address book is shown
* @property network the current send network; only contacts with an address in this network are shown
* @property queryFlow the live recipient-input text used to filter the block
* @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides
@ -24,7 +22,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent {
* @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode
*/
data class Params(
val userWalletId: UserWalletId,
val network: Network,
val queryFlow: StateFlow<String>,
val onContactClick: (MatchedContact) -> Unit,

View file

@ -12,6 +12,7 @@ import kotlinx.collections.immutable.ImmutableList
data class MatchedContact(
val contactId: String,
val walletId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkId: String,

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.domain.account)
implementation(projects.domain.addressBook)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
/** Common */
implementation(projects.common.ui)

View file

@ -3,7 +3,9 @@ package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -44,26 +46,44 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
)
},
)
RecipientRow(
modifier = Modifier.padding(horizontal = 16.dp),
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH(20.dp)
NetworkBlock(
BoxWithConstraints(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary),
chosenNetworkStateUM = state.chosenNetworkStateUM,
onNetworkSelectClick = state.onNetworkClick,
)
PrimaryButton(state.buttonUM)
.weight(1f),
) {
val minContentHeight = maxHeight
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier
.heightIn(min = minContentHeight)
.imePadding(),
) {
RecipientRow(
modifier = Modifier.padding(horizontal = 16.dp),
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH(20.dp)
NetworkBlock(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary),
chosenNetworkStateUM = state.chosenNetworkStateUM,
onNetworkSelectClick = state.onNetworkClick,
)
PrimaryButton(state.buttonUM)
}
}
}
}
}

View file

@ -134,6 +134,7 @@ private fun Preview_AddressSelectorList() {
AddressSelectorList(
contact = MatchedContact(
contactId = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,

View file

@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
@ -11,11 +12,7 @@ import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@ -25,6 +22,7 @@ internal class ContactsBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: ContactsBlockStateController,
getContactsUseCase: GetContactsUseCase,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
@ -32,13 +30,19 @@ internal class ContactsBlockModel @Inject constructor(
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
init {
params.queryFlow
.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) }
.onEach { contacts ->
combine(
params.queryFlow.flatMapLatest { query ->
getContactsUseCase(query = query, userWalletId = null)
},
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { contacts, wallets -> contacts to wallets.values.toList() }
.onEach { (contacts, wallets) ->
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
stateController.update(
UpdateContactsBlockStateTransformer(
matched = matched,
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
onSeeAllClick = params.onSeeAllClick,
onContactClick = params.onContactClick,
),

View file

@ -9,6 +9,8 @@ import kotlinx.collections.immutable.toImmutableList
/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */
internal class UpdateContactsBlockStateTransformer(
private val matched: List<MatchedContact>,
private val walletNamesById: Map<String, String>,
private val shouldShowWalletName: Boolean,
private val onSeeAllClick: () -> Unit,
private val onContactClick: (MatchedContact) -> Unit,
) : Transformer<ContactsBlockUM> {
@ -29,9 +31,11 @@ internal class UpdateContactsBlockStateTransformer(
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
id = contactId,
walletId = walletId,
name = name,
icon = icon,
networkAddressCount = entries.size,
walletName = if (shouldShowWalletName) walletNamesById[walletId] else null,
onClick = { onContactClick(this) },
)

View file

@ -74,6 +74,7 @@ private fun Preview_ContactsBlock() {
contacts = persistentListOf(
ContactUM(
id = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
@ -84,6 +85,7 @@ private fun Preview_ContactsBlock() {
),
ContactUM(
id = "2",
walletId = "01",
name = "Alice",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,

View file

@ -12,8 +12,6 @@ import kotlinx.collections.immutable.toImmutableList
*/
internal object ContactMatcher {
private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
return contacts.mapNotNull { contact ->
val entries = contact.addressEntries.filter { it.networkId.value == networkId }
@ -21,11 +19,9 @@ internal object ContactMatcher {
MatchedContact(
contactId = contact.id.value,
walletId = contact.walletId.stringValue,
name = contact.name.value,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = contact.resolveIconColor(),
),
icon = contact.toAvatarIcon(),
networkId = networkId,
entries = entries.map { entry ->
MatchedContact.ContactAddress(
@ -38,6 +34,10 @@ internal object ContactMatcher {
}
}
private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color =
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR
/** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */
private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
?: CryptoPortfolioIcon.Color.Azure,
)
}

View file

@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.StringsSigns
@Composable
internal fun ContactRow(contact: ContactUM) {
@ -33,12 +34,14 @@ internal fun ContactRow(contact: ContactUM) {
)
},
subtitleSlot = {
val addresses = pluralStringResourceSafe(
R.plurals.address_book_addresses,
contact.networkAddressCount,
contact.networkAddressCount,
)
TangemRowText(
text = pluralStringResourceSafe(
R.plurals.address_book_addresses,
contact.networkAddressCount,
contact.networkAddressCount,
),
text = contact.walletName?.let { walletName -> "$addresses ${StringsSigns.DOT} $walletName" }
?: addresses,
role = TangemRowTextRole.Subtitle,
)
},
@ -52,11 +55,13 @@ private fun Preview_ContactRow() {
ContactRow(
ContactUM(
id = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
walletName = "Wallet 1",
networkAddressCount = 1,
onClick = {},
),

View file

@ -49,6 +49,7 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -216,7 +217,7 @@ private fun ContactColor(colors: EditContactUM.Colors) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
colors.list.fastForEach { color ->

View file

@ -7,31 +7,33 @@ import com.tangem.core.decompose.di.ModelScoped
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.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Backs the contacts list. The list content is the same however the address book was opened the open
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]).
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]).
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network a single matching address is
* returned right away, several open the address selector first.
*/
@Suppress("LongParameterList", "NamedArguments")
@OptIn(ExperimentalCoroutinesApi::class)
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
@ -39,7 +41,8 @@ internal class AddressBookListModel @Inject constructor(
private val stateController: AddressBookListStateController,
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
private val getContactsUseCase: GetContactsUseCase,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
@ -49,32 +52,77 @@ internal class AddressBookListModel @Inject constructor(
/** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
val selectorNavigation = SlotNavigation<MatchedContact>()
init {
when (val mode = params.mode) {
// Browse/manage: full list UI is TODO [REDACTED_TASK_KEY].
AddressBookRoute.ListMode.Default -> stateController.update(
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
)
// Pick a recipient: same list, the tap returns the chosen address.
is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId)
}
}
private val searchQuery = MutableStateFlow(value = "")
private val searchActive = MutableStateFlow(value = false)
private val selectedWalletId = MutableStateFlow<String?>(value = null)
private fun observeSelectionContacts(networkId: String) {
getContactsUseCase(query = "")
.onEach { contacts ->
stateController.update(
UpdateAddressBookListSelectionStateTransformer(
matched = ContactMatcher.match(contacts = contacts, networkId = networkId),
onAddContactClick = params.onAddContactClick,
onContactClick = ::onPickContact,
),
)
}
private val allContacts: SharedFlow<List<VerifiedContact>> =
getVerifiedContactsInteractor(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init {
val matchedContacts = searchQuery.flatMapLatest { query ->
if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null)
}
combine(
allContacts,
matchedContacts,
searchQuery,
combine(selectedWalletId, searchActive) { selected, active -> selected to active },
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { all, matched, query, (selected, active), wallets ->
ListInputs(
allContacts = all,
matchedContacts = matched,
query = query,
selectedWalletId = selected,
isSearchActive = active,
wallets = wallets,
)
}
.onEach(::updateState)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun updateState(inputs: ListInputs) {
stateController.update(
UpdateAddressBookListContentTransformer(
allContacts = inputs.allContacts,
matchedContacts = inputs.matchedContacts,
mode = params.mode,
wallets = inputs.wallets,
selectedWalletId = inputs.selectedWalletId,
query = inputs.query,
isSearchActive = inputs.isSearchActive,
onContactClick = params.onContactClick,
onPickContact = ::onPickContact,
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearQuery = ::onClearQuery,
onChipSelected = ::onChipSelected,
onAddContactClick = params.onAddContactClick,
),
)
}
private fun onQueryChange(query: String) {
searchQuery.value = query
}
private fun onActiveChange(active: Boolean) {
searchActive.value = active
}
private fun onClearQuery() {
searchQuery.value = ""
}
private fun onChipSelected(walletId: String?) {
if (selectedWalletId.value == walletId) return
selectedWalletId.value = walletId
}
private fun onPickContact(contact: MatchedContact) {
val singleEntry = contact.entries.singleOrNull()
if (singleEntry != null) {
@ -89,4 +137,13 @@ internal class AddressBookListModel @Inject constructor(
selectorNavigation.dismiss()
router.pop()
}
private data class ListInputs(
val allContacts: List<VerifiedContact>,
val matchedContacts: List<VerifiedContact>,
val query: String,
val selectedWalletId: String?,
val isSearchActive: Boolean,
val wallets: Map<UserWalletId, UserWallet>,
)
}

View file

@ -0,0 +1,129 @@
package com.tangem.features.addressbook.list.state.transformers
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.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter
import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
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 mode: AddressBookRoute.ListMode,
private val selectedWalletId: String?,
private val query: String,
private val isSearchActive: Boolean,
private val onContactClick: (String) -> Unit,
private val onPickContact: (MatchedContact) -> Unit,
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
private val onClearQuery: () -> Unit,
private val onChipSelected: (String?) -> Unit,
private val onAddContactClick: () -> Unit,
) : Transformer<AddressBookListUM> {
private val orderedWalletIds: List<String> = wallets.values.map { it.walletId.stringValue }
private val walletNamesById: Map<String, String> = wallets.values.associate { it.walletId.stringValue to it.name }
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
val matchedItems = matchedItems()
if (matchedItems.isEmpty() && query.isBlank()) {
return AddressBookListUM.Empty(onAddClick = onAddContactClick)
}
val matchingWalletIds = matchedItems.map { it.walletId }.distinct()
val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds }
val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty()
// On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to.
val shouldShowWalletName = areChipsVisible && effectiveSelected == null
val displayContacts = matchedItems
.filter { effectiveSelected == null || it.walletId == effectiveSelected }
.map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it }
.toImmutableList()
return AddressBookListUM.Content(
searchBar = buildSearchBar(),
chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(),
contacts = displayContacts,
isNothingFound = matchedItems.isEmpty(),
contentMode = contentMode(),
)
}
private fun matchedItems(): List<ContactUM> = when (val mode = mode) {
AddressBookRoute.ListMode.Default ->
DefaultContactConverter(onContactClick).convertList(matchedContacts)
is AddressBookRoute.ListMode.Selector ->
SelectorContactConverter(onPickContact)
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, 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 }
is AddressBookRoute.ListMode.Selector ->
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
}
private fun contentMode(): ContentMode = when (mode) {
AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick)
is AddressBookRoute.ListMode.Selector -> ContentMode.Select
}
private fun buildSearchBar(): TangemSearch.State = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = onQueryChange,
isActive = isSearchActive,
onActiveChange = onActiveChange,
onClearClick = onClearQuery,
onCloseClick = { onActiveChange(false) },
)
private fun buildChips(matchingWalletIds: List<String>, effectiveSelected: String?) = buildList {
add(
AddressBookChipUM(
id = ALL_CHIP_ID,
text = resourceReference(R.string.common_all),
isSelected = effectiveSelected == null,
onClick = { onChipSelected(null) },
),
)
orderedWalletIds
.filter { it in matchingWalletIds }
.forEach { walletId ->
add(
AddressBookChipUM(
id = walletId,
text = stringReference(walletNamesById[walletId] ?: walletId),
isSelected = walletId == effectiveSelected,
onClick = { onChipSelected(walletId) },
iconRes = R.drawable.ic_key_card_20,
),
)
}
}.toImmutableList()
private companion object {
const val ALL_CHIP_ID = "all"
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.utils.transformer.Transformer
/**
* Wires the "add contact" callback owned by the container into the initial (empty) list state.
*/
internal class UpdateAddressBookListInitialStateTransformer(
private val onAddContactClick: () -> Unit,
) : Transformer<AddressBookListUM> {
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
return when (prevState) {
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
is AddressBookListUM.Content -> prevState.copy(
contentMode = ContentMode.Default(onAddClick = onAddContactClick),
)
}
}
}

View file

@ -1,38 +0,0 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
/**
* Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the
* user can still add a contact.
*/
internal class UpdateAddressBookListSelectionStateTransformer(
private val matched: List<MatchedContact>,
private val onAddContactClick: () -> Unit,
private val onContactClick: (MatchedContact) -> Unit,
) : Transformer<AddressBookListUM> {
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
return if (matched.isEmpty()) {
AddressBookListUM.Empty(onAddClick = onAddContactClick)
} else {
AddressBookListUM.Content(
contacts = matched.map { it.toContactUM() }.toImmutableList(),
contentMode = ContentMode.Select,
)
}
}
private fun MatchedContact.toContactUM(): ContactUM = ContactUM(
id = contactId,
name = name,
icon = icon,
networkAddressCount = entries.size,
onClick = { onContactClick(this) },
)
}

View file

@ -0,0 +1,30 @@
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.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> {
override fun convert(value: VerifiedContact): ContactUM {
val contact = value.contact
val name = contact.name.value
return ContactUM(
id = contact.id.value,
walletId = contact.walletId.stringValue,
name = name,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon }
?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
?: CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = contact.addressEntries.size,
onClick = { onContactClick(contact.id.value) },
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class SelectorContactConverter(
private val onPickContact: (MatchedContact) -> Unit,
) : Converter<MatchedContact, ContactUM> {
override fun convert(value: MatchedContact): ContactUM = ContactUM(
id = value.contactId,
walletId = value.walletId,
name = value.name,
icon = value.icon,
networkAddressCount = value.entries.size,
onClick = { onPickContact(value) },
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
@Composable
internal fun AddressBookChip(state: AddressBookChipUM, modifier: Modifier = Modifier) {
val backgroundColor = if (state.isSelected) {
TangemTheme.colors2.tabs.backgroundPrimary
} else {
TangemTheme.colors2.tabs.backgroundSecondary
}
val textColor = if (state.isSelected) {
TangemTheme.colors2.tabs.textPrimary
} else {
TangemTheme.colors2.tabs.textSecondary
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape = CircleShape)
.background(color = backgroundColor)
.selectable(
selected = state.isSelected,
onClick = state.onClick,
role = Role.Tab,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = state.text.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = textColor,
maxLines = 1,
)
if (state.iconRes != null) {
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp),
painter = painterResource(id = state.iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
)
}
}
}

View file

@ -1,32 +1,38 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.core.ui.res.generated.icons.*
import com.tangem.features.addressbook.common.ui.ContactRow
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun AddressBookListScreen(
@ -34,7 +40,7 @@ internal fun AddressBookListScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(modifier = modifier.navigationBarsPadding()) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),
@ -68,76 +74,93 @@ internal fun AddressBookListScreen(
)
},
)
LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {
items(items = state.contacts, key = ContactUM::id) { contact ->
ContactRow(contact = contact)
TangemSearch(
state = state.searchBar,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
if (state.chips.isNotEmpty()) {
WalletChips(chips = state.chips)
}
if (state.isNothingFound) {
NothingFoundContent()
} else {
LazyColumn(
modifier = Modifier
.imePadding()
.padding(top = 16.dp)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = 12.dp,
),
) {
items(items = state.contacts, key = ContactUM::id) { contact ->
ContactRow(contact = contact)
}
}
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_AddressBookListScreen() {
TangemThemePreviewRedesign {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
AddressBookListScreen(
state = AddressBookListUM.Content(
contacts = persistentListOf(
ContactUM(
id = "1",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = 1,
onClick = {},
),
ContactUM(
id = "2",
name = "Alice",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.UFOGreen,
),
networkAddressCount = 3,
onClick = {},
),
),
contentMode = ContentMode.Default(onAddClick = {}),
),
onBackClick = {},
)
AddressBookListScreen(
state = AddressBookListUM.Content(
contacts = persistentListOf(
ContactUM(
id = "1",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = 1,
onClick = {},
),
ContactUM(
id = "2",
name = "Alice",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.UFOGreen,
),
networkAddressCount = 3,
onClick = {},
),
),
contentMode = ContentMode.Select,
),
onBackClick = {},
)
private fun WalletChips(chips: ImmutableList<AddressBookChipUM>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = chips, key = AddressBookChipUM::id) { chip ->
AddressBookChip(state = chip)
}
}
}
@Composable
private fun ColumnScope.NothingFoundContent() {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(80.dp)
.background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.ic_search_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(28.dp),
)
}
Text(
modifier = Modifier.padding(top = 32.dp),
text = stringResourceSafe(R.string.common_no_results),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.small,
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
private fun Preview_AddressBookListScreen(
@PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario,
) {
TangemThemePreviewRedesign {
AddressBookListScreen(
state = scenario.state,
onBackClick = {},
)
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.features.addressbook.list.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconUM
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.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import kotlinx.collections.immutable.persistentListOf
internal data class AddressBookListPreviewScenario(
val title: String,
val state: AddressBookListUM.Content,
)
internal object AddressBookListPreviewFixtures {
private val scenarioDefaultWithContacts = AddressBookListPreviewScenario(
title = "Default chips and contacts",
state = AddressBookListUM.Content(
searchBar = searchBar(query = ""),
chips = persistentListOf(
AddressBookChipUM(
id = "all",
text = resourceReference(R.string.common_all),
isSelected = true,
onClick = {},
),
AddressBookChipUM(
id = "00",
text = stringReference("Wallet 1"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
AddressBookChipUM(
id = "01",
text = stringReference("Wallet 2"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
),
contacts = persistentListOf(
contact(
walletId = "00",
name = "Binance",
color = CryptoPortfolioIcon.Color.Azure,
count = 1,
),
contact(
walletId = "01",
name = "Alice",
color = CryptoPortfolioIcon.Color.UFOGreen,
count = 3,
),
),
isNothingFound = false,
contentMode = ContentMode.Default(onAddClick = {}),
),
)
val scenarioSelectNothingFound = AddressBookListPreviewScenario(
title = "Select nothing found",
state = AddressBookListUM.Content(
searchBar = searchBar(query = "Antonio"),
chips = persistentListOf(),
contacts = persistentListOf(),
isNothingFound = true,
contentMode = ContentMode.Select,
),
)
fun allScenarios(): List<AddressBookListPreviewScenario> = listOf(
scenarioDefaultWithContacts,
scenarioSelectNothingFound,
)
private fun searchBar(query: String) = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = {},
isActive = false,
onActiveChange = {},
)
private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM(
id = name + walletId,
walletId = walletId,
name = name,
icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color),
networkAddressCount = count,
onClick = {},
)
}
/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */
internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider<AddressBookListPreviewScenario> {
override val values: Sequence<AddressBookListPreviewScenario>
get() = AddressBookListPreviewFixtures.allScenarios().asSequence()
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class AddressBookChipUM(
val id: String,
val text: TextReference,
val isSelected: Boolean,
val onClick: () -> Unit,
@DrawableRes val iconRes: Int? = null,
)

View file

@ -1,20 +1,31 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds2.search.TangemSearch
import kotlinx.collections.immutable.ImmutableList
/**
* UI state of the contacts list. The list itself is the same however the address book was opened it is either
* [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a
* contact tap does, which is captured by [ContactUM.onClick], not by a separate state.
* [Empty] (no contacts at all) or shows [Content]. How the address book was opened (browse vs. pick a recipient) only
* changes what a contact tap does, which is captured by [ContactUM.onClick] and [Content.contentMode].
*/
@Immutable
internal sealed interface AddressBookListUM {
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
/**
* @property searchBar always shown so the user can filter contacts across all wallets.
* @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden.
* @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true.
* @property isNothingFound true when the active search matched nothing show the "no results" stub instead of the
* list (the search bar stays visible so the query can be edited).
*/
data class Content(
val searchBar: TangemSearch.State,
val chips: ImmutableList<AddressBookChipUM>,
val contacts: ImmutableList<ContactUM>,
val isNothingFound: Boolean,
val contentMode: ContentMode,
) : AddressBookListUM
}

View file

@ -6,8 +6,10 @@ import com.tangem.common.ui.account.AccountIconUM
@Immutable
internal data class ContactUM(
val id: String,
val walletId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkAddressCount: Int,
val walletName: String? = null,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,182 @@
package com.tangem.features.addressbook.list.state.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import org.junit.jupiter.api.Test
internal class UpdateAddressBookListContentTransformerTest {
private val wallet1 = "00"
private val wallet2 = "01"
private val wallets: Map<UserWalletId, UserWallet> = linkedMapOf(
UserWalletId(stringValue = wallet1) to wallet(wallet1, "Wallet 1"),
UserWalletId(stringValue = wallet2) to wallet(wallet2, "Wallet 2"),
)
@Test
fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips).isEmpty()
assertThat(content.contacts).hasSize(2)
assertThat(content.isNothingFound).isFalse()
// Single-wallet book has no chips, so the wallet name is not shown.
assertThat(content.contacts.none { it.walletName != null }).isTrue()
}
@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"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
assertThat(content.chips.first().isSelected).isTrue() // All
assertThat(content.chips.drop(1).none { it.isSelected }).isTrue()
assertThat(content.contacts).hasSize(2)
// On the "All" chip of a multi-wallet book each contact shows its wallet name.
assertThat(content.contacts.map { it.walletName }).containsExactly("Wallet 1", "Wallet 2").inOrder()
}
@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"))
// Act
val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
assertThat(content.chips.first().isSelected).isFalse() // All not selected
assertThat(content.contacts.map { it.name }).containsExactly("Bob")
// A specific wallet is selected, so the (redundant) wallet name is not shown.
assertThat(content.contacts.single().walletName).isNull()
}
@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"))
// Act
val result = transform(allContacts = all, matchedContacts = matched, query = "Anto")
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1).inOrder()
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
assertThat(content.isNothingFound).isFalse()
}
@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"))
// Act — selected wallet2, but query matched only wallet1
val result = transform(
allContacts = all,
matchedContacts = matched,
selectedWalletId = wallet2,
query = "Anto",
)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.first().isSelected).isTrue() // All selected again
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
}
@Test
fun `GIVEN no contacts WHEN blank query THEN Empty`() {
// Act
val result = transform(allContacts = emptyList(), matchedContacts = emptyList())
// Assert
assertThat(result).isInstanceOf(AddressBookListUM.Empty::class.java)
}
@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"))
// Act
val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz")
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.isNothingFound).isTrue()
assertThat(content.contacts).isEmpty()
assertThat(content.chips).isEmpty()
}
private fun transform(
allContacts: List<VerifiedContact>,
matchedContacts: List<VerifiedContact>,
selectedWalletId: String? = null,
query: String = "",
): AddressBookListUM = UpdateAddressBookListContentTransformer(
allContacts = allContacts,
matchedContacts = matchedContacts,
mode = AddressBookRoute.ListMode.Default,
wallets = wallets,
selectedWalletId = selectedWalletId,
query = query,
isSearchActive = false,
onContactClick = {},
onPickContact = {},
onQueryChange = {},
onActiveChange = {},
onClearQuery = {},
onChipSelected = {},
onAddContactClick = {},
).transform(prevState = AddressBookListUM.Empty(onAddClick = {}))
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",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId(name),
address = "addr-$name",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
),
invalidEntries = emptyList(),
)
}