Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-23 13:35:52 +02:00
parent 20484baaf1
commit bee4019e8b
49 changed files with 1256 additions and 72 deletions

View file

@ -180,6 +180,7 @@ sealed class AppRoute(val path: String) : Route {
path = when (addressBookOpenMode) { path = when (addressBookOpenMode) {
is AddressBookOpenMode.WithContactCreation -> is AddressBookOpenMode.WithContactCreation ->
"/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}" "/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}"
is AddressBookOpenMode.ContactSelection -> "/address_book/select/${addressBookOpenMode.networkId}"
AddressBookOpenMode.Default -> "/address_book" AddressBookOpenMode.Default -> "/address_book"
}, },
) )

View file

@ -14,4 +14,13 @@ sealed interface AddressBookOpenMode {
val address: String, val address: String,
val networkId: String, val networkId: String,
) : AddressBookOpenMode ) : AddressBookOpenMode
/**
* Opened from the Send flow to pick a recipient. The list is filtered by [networkId] (the current send network),
* and the chosen contact's address is delivered back via `ContactSelectionTrigger` rather than navigation.
*/
@Serializable
data class ContactSelection(
val networkId: String,
) : AddressBookOpenMode
} }

View file

@ -1,8 +1,10 @@
package com.tangem.common.ui.account package com.tangem.common.ui.account
import androidx.compose.runtime.Immutable
import com.tangem.domain.models.account.CryptoPortfolioIcon.Color import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon
@Immutable
sealed class AccountIconUM { sealed class AccountIconUM {
data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM() data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM()

View file

@ -102,6 +102,7 @@
<item quantity="one">%d address</item> <item quantity="one">%d address</item>
<item quantity="other">%d addresses</item> <item quantity="other">%d addresses</item>
</plurals> </plurals>
<string name="address_book_choose_address">Choose address</string>
<string name="address_book_contact">Contact</string> <string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string> <string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string> <string name="address_book_copy_address">Copy address</string>

View file

@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
enum class AccountIconSize { enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge, Contact
} }
/** /**
@ -133,6 +133,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11 AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium
AccountIconSize.Contact -> TangemTheme.typography3.body.medium
} }
val textSize by animateFloatAsState( val textSize by animateFloatAsState(
@ -168,6 +169,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 20.dp AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp AccountIconSize.RedesignExtraSmall -> 8.dp
AccountIconSize.RedesignLarge -> 32.dp AccountIconSize.RedesignLarge -> 32.dp
AccountIconSize.Contact -> 20.dp
} }
fun AccountIconSize.toBoxSize(): Dp = when (this) { fun AccountIconSize.toBoxSize(): Dp = when (this) {
@ -179,6 +181,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 40.dp AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp AccountIconSize.RedesignExtraSmall -> 16.dp
AccountIconSize.RedesignLarge -> 80.dp AccountIconSize.RedesignLarge -> 80.dp
AccountIconSize.Contact -> 40.dp
} }
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@ -190,6 +193,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 12.dp AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp AccountIconSize.RedesignExtraSmall -> 6.dp
AccountIconSize.RedesignLarge -> 80.dp AccountIconSize.RedesignLarge -> 80.dp
AccountIconSize.Contact -> 100.dp
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@ -233,7 +237,8 @@ private fun Sample() {
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge
AccountIconSize.RedesignLarge -> AccountIconSize.Default AccountIconSize.RedesignLarge -> AccountIconSize.Contact
AccountIconSize.Contact -> AccountIconSize.Default
} }
}) { Text("Change") } }) { Text("Change") }

View file

@ -19,9 +19,9 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -9,6 +9,7 @@ data class AddressEntry(
val id: AddressEntryId, val id: AddressEntryId,
val address: String, val address: String,
val networkId: Network.RawID, val networkId: Network.RawID,
val networkName: String,
val memo: String?, val memo: String?,
val signature: String, val signature: String,
) )

View file

@ -259,6 +259,7 @@ internal class AddressBookCipherTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = memo, memo = memo,
signature = "", signature = "",
networkName = "Ethereum",
) )
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1) private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)

View file

@ -5,23 +5,14 @@ import arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError import com.tangem.domain.addressbook.error.SaveContactError
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.repository.AddressBookRepository import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider
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.transaction.error.SignHashesError import com.tangem.domain.transaction.error.SignHashesError
import io.mockk.clearMocks import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -57,6 +48,7 @@ class CreateContactUseCaseTest {
networkId = networkRawId, networkId = networkRawId,
memo = "memo", memo = "memo",
signature = "sig", signature = "sig",
networkName = "Ethereum",
), ),
) )
@ -165,6 +157,7 @@ class CreateContactUseCaseTest {
networkId = networkRawId, networkId = networkRawId,
memo = null, memo = null,
signature = "sig", signature = "sig",
networkName = "Ethereum",
), ),
), ),
) )

View file

@ -101,6 +101,7 @@ class GetContactsUseCaseTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = null, memo = null,
signature = "sig", signature = "sig",
networkName = "Ethereum",
), ),
), ),
) )

View file

@ -3,13 +3,7 @@ package com.tangem.domain.addressbook.usecase
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
import com.tangem.domain.addressbook.model.AddressEntriesVerification import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
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
@ -103,6 +97,7 @@ class GetVerifiedContactsUseCaseTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = null, memo = null,
signature = "sig-$id", signature = "sig-$id",
networkName = "Ethereum",
) )
private fun contact(name: String, entries: List<AddressEntry>): Contact = Contact( private fun contact(name: String, entries: List<AddressEntry>): Contact = Contact(

View file

@ -137,6 +137,7 @@ class SignAddressEntriesUseCaseTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = memo, memo = memo,
signature = "", signature = "",
networkName = "Ethereum",
) )
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {

View file

@ -54,6 +54,7 @@ class UpdateContactUseCaseTest {
networkId = networkRawId, networkId = networkRawId,
memo = "memo", memo = "memo",
signature = "sig2", signature = "sig2",
networkName = "Ethereum",
), ),
) )
@ -137,6 +138,7 @@ class UpdateContactUseCaseTest {
networkId = networkRawId, networkId = networkRawId,
memo = null, memo = null,
signature = "sig", signature = "sig",
networkName = "Ethereum",
), ),
), ),
) )

View file

@ -75,6 +75,7 @@ class ValidateContactNameUseCaseTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = null, memo = null,
signature = "sig", signature = "sig",
networkName = "Ethereum",
), ),
), ),
) )

View file

@ -165,6 +165,7 @@ class VerifyAddressEntriesUseCaseTest {
networkId = Network.RawID("ethereum"), networkId = Network.RawID("ethereum"),
memo = memo, memo = memo,
signature = signature, signature = signature,
networkName = "Ethereum",
) )
private fun expectedPayload(contact: Contact, entry: AddressEntry): String = private fun expectedPayload(contact: Contact, entry: AddressEntry): String =

View file

@ -12,6 +12,7 @@ dependencies {
/* Project - Common */ /* Project - Common */
api(projects.common.routing) api(projects.common.routing)
implementation(projects.common.ui)
/* Project - Domain */ /* Project - Domain */
implementation(projects.domain.models) implementation(projects.domain.models)
@ -22,4 +23,7 @@ dependencies {
/* Compose */ /* Compose */
implementation(deps.compose.runtime) implementation(deps.compose.runtime)
/** Other */
implementation(deps.kotlin.immutable.collections)
} }

View file

@ -0,0 +1,33 @@
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
/**
* The contacts block shown on the Send address-entry screen, below the "recent" block. Lists up to five contacts that
* have an address in [Params.network], filtered live by [Params.queryFlow] (the recipient-input text, matched against
* contact name or address). Hidden when there are no matching contacts.
*/
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
* whether to apply it directly (single entry) or open the address selector (multiple entries)
* @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,
val onSeeAllClick: () -> Unit,
)
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
/**
* Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a
* concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact].
*/
interface AddressSelectorComponent : ComposableBottomSheetComponent {
interface Factory : ComponentFactory<Params, AddressSelectorComponent>
data class Params(
val contact: MatchedContact,
val onAddressSelected: (SelectedContact) -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook
import kotlinx.coroutines.flow.SharedFlow
/**
* Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature
* requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot
* [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear.
*
* Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern.
*/
interface ContactSelectionTrigger {
fun trigger(contact: SelectedContact)
}
interface ContactSelectionListener {
val resultFlow: SharedFlow<SelectedContact>
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.addressbook
import com.tangem.common.ui.account.AccountIconUM
import kotlinx.collections.immutable.ImmutableList
/**
* A contact together with its address entries that match a given network. Emitted when a contact is tapped during
* selection; the host decides what to do with it:
* - exactly one [entries] item build a [SelectedContact] and proceed straight away;
* - more than one open the address selector so the user picks a concrete address first.
*/
data class MatchedContact(
val contactId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkId: String,
val entries: ImmutableList<ContactAddress>,
) {
/** Resolves this contact to a concrete pick using one of its [entries]. */
fun toSelectedContact(entry: ContactAddress): SelectedContact = SelectedContact(
contactId = contactId,
name = name,
icon = icon,
address = entry.address,
networkId = networkId,
memo = entry.memo,
)
/** A single network-matching address of the contact. */
data class ContactAddress(
val address: String,
val memo: String?,
val networkName: String,
)
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.addressbook
import com.tangem.common.ui.account.AccountIconUM
/**
* A single resolved address-book pick. Produced once the concrete address within a contact is known either directly
* (the contact has a single matching-network address) or after the user chose one in the address selector.
*
* Feature-agnostic: any feature that opens the address book for selection receives this result.
*
* @property contactId the id of the source [com.tangem.domain.addressbook.model.Contact]
* @property name the contact name to display
* @property icon the contact avatar (initials + color), reusing the account icon UI model
* @property address the chosen on-chain address
* @property networkId raw id of the network the address belongs to
* @property memo optional memo/destination tag (only meaningful for networks that support it)
*/
data class SelectedContact(
val contactId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val address: String,
val networkId: String,
val memo: String?,
)

View file

@ -11,19 +11,17 @@ import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import javax.inject.Inject import javax.inject.Inject
@ModelScoped @ModelScoped
internal class AddAddressStateController @Inject constructor() { internal class AddAddressStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<AddAddressUM> = MutableStateFlow(value = getInitialState()) val uiState: StateFlow<AddAddressUM>
field = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<AddAddressUM> get() = mutableUiState.asStateFlow()
fun update(transformer: Transformer<AddAddressUM>) { fun update(transformer: Transformer<AddAddressUM>) {
mutableUiState.update(function = transformer::transform) uiState.update(function = transformer::transform)
} }
private fun getInitialState(): AddAddressUM = AddAddressUM( private fun getInitialState(): AddAddressUM = AddAddressUM(

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addressselector
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: AddressSelectorComponent.Params,
) : AddressSelectorComponent, AppComponentContext by appComponentContext {
override fun dismiss() = params.onDismiss()
@Composable
override fun BottomSheet() {
AddressSelectorBottomSheet(
contact = params.contact,
onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) },
onDismiss = ::dismiss,
)
}
@AssistedFactory
interface Factory : AddressSelectorComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressSelectorComponent.Params,
): DefaultAddressSelectorComponent
}
}

View file

@ -0,0 +1,159 @@
package com.tangem.features.addressbook.addressselector.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun AddressSelectorBottomSheet(
contact: MatchedContact,
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
onDismiss: () -> Unit,
) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
title = {
TangemTopBar(
title = resourceReference(R.string.address_book_choose_address),
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = onDismiss,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
},
content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) },
footer = {
TangemButton(
onClick = onDismiss,
text = resourceReference(R.string.common_cancel),
variant = TangemButton.Variant.Secondary,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
},
)
}
@Composable
private fun AddressSelectorList(
contact: MatchedContact,
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(20.dp),
)
.verticalScroll(rememberScrollState()),
) {
contact.entries.fastForEach { entry ->
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
}
}
}
@Composable
private fun AddressRow(entry: MatchedContact.ContactAddress, onClick: () -> Unit) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(entry.address),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
},
titleSlot = {
TangemRowText(
text = entry.address,
role = TangemRowTextRole.Title,
overflow = TextOverflow.MiddleEllipsis,
)
},
subtitleSlot = {
TangemRowText(
text = entry.networkName,
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_AddressSelectorList() {
TangemThemePreviewRedesign {
AddressSelectorList(
contact = MatchedContact(
contactId = "1",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkId = "ethereum",
entries = persistentListOf(
MatchedContact.ContactAddress(
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
memo = null,
networkName = "Ethereum",
),
MatchedContact.ContactAddress(
address = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
memo = "12345",
networkName = "Ethereum",
),
),
),
onAddressClick = {},
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.addressbook.block
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.block.model.ContactsBlockModel
import com.tangem.features.addressbook.block.ui.ContactsBlock
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: AddressBookContactsBlockComponent.Params,
) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext {
private val model: ContactsBlockModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
ContactsBlock(state = state, modifier = modifier)
}
@AssistedFactory
interface Factory : AddressBookContactsBlockComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookContactsBlockComponent.Params,
): DefaultAddressBookContactsBlockComponent
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.addressbook.block.model
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.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.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 javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@ModelScoped
internal class ContactsBlockModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: ContactsBlockStateController,
getContactsUseCase: GetContactsUseCase,
) : Model() {
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
init {
params.queryFlow
.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) }
.onEach { contacts ->
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
stateController.update(
UpdateContactsBlockStateTransformer(
matched = matched,
onSeeAllClick = params.onSeeAllClick,
onContactClick = params.onContactClick,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook.block.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class ContactsBlockStateController @Inject constructor() {
val uiState: StateFlow<ContactsBlockUM>
field = MutableStateFlow<ContactsBlockUM>(value = ContactsBlockUM.Hidden)
fun update(transformer: Transformer<ContactsBlockUM>) {
uiState.update(function = transformer::transform)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.addressbook.block.state.transformers
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.transformer.Transformer
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 onSeeAllClick: () -> Unit,
private val onContactClick: (MatchedContact) -> Unit,
) : Transformer<ContactsBlockUM> {
override fun transform(prevState: ContactsBlockUM): ContactsBlockUM {
return if (matched.isEmpty()) {
ContactsBlockUM.Hidden
} else {
ContactsBlockUM.Content(
contacts = matched
.take(MAX_CONTACTS)
.map { it.toRowUM() }.toImmutableList(),
onSeeAllClick = onSeeAllClick,
shouldShowSeeAll = matched.size > MAX_CONTACTS,
)
}
}
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
id = contactId,
name = name,
icon = icon,
networkAddressCount = entries.size,
onClick = { onContactClick(this) },
)
private companion object {
const val MAX_CONTACTS = 5
}
}

View file

@ -0,0 +1,101 @@
package com.tangem.features.addressbook.block.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.ui.ContactRow
import com.tangem.features.addressbook.list.ui.state.ContactUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun ContactsBlock(state: ContactsBlockUM, modifier: Modifier = Modifier) {
if (state !is ContactsBlockUM.Content) return
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.secondary),
) {
Header(onSeeAllClick = state.onSeeAllClick, shouldShowSeeAll = state.shouldShowSeeAll)
state.contacts.forEach { contact ->
ContactRow(contact = contact)
}
}
}
@Composable
private fun Header(onSeeAllClick: () -> Unit, shouldShowSeeAll: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 4.dp),
) {
Text(
text = stringResourceSafe(R.string.address_book_title),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
modifier = Modifier.weight(1f),
)
if (shouldShowSeeAll) {
Text(
text = stringResourceSafe(R.string.common_view_all),
color = TangemTheme.colors3.text.brand,
style = TangemTheme.typography3.caption.medium,
modifier = Modifier.clickable(onClick = onSeeAllClick),
)
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_ContactsBlock() {
TangemThemePreviewRedesign {
ContactsBlock(
state = ContactsBlockUM.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 = {},
),
),
onSeeAllClick = {},
shouldShowSeeAll = true,
),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.addressbook.block.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.features.addressbook.list.ui.state.ContactUM
import kotlinx.collections.immutable.ImmutableList
/** UI state of the Send contacts block. [Hidden] is rendered as nothing (no matching contacts / feature off). */
@Immutable
internal sealed interface ContactsBlockUM {
data object Hidden : ContactsBlockUM
data class Content(
val shouldShowSeeAll: Boolean,
val contacts: ImmutableList<ContactUM>,
val onSeeAllClick: () -> Unit,
) : ContactsBlockUM
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.addressbook.common
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
@ -15,19 +16,23 @@ import javax.inject.Inject
* Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the * Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the
* container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature. * container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature.
*/ */
internal class AddressBookChildFactory @Inject constructor() { internal class AddressBookChildFactory @Inject constructor(
private val addressSelectorFactory: AddressSelectorComponent.Factory,
) {
fun createChild( fun createChild(
route: AddressBookRoute, route: AddressBookRoute,
context: AppComponentContext, context: AppComponentContext,
clickIntents: AddressBookClickIntents, clickIntents: AddressBookClickIntents,
): ComposableContentComponent = when (route) { ): ComposableContentComponent = when (route) {
AddressBookRoute.List -> DefaultAddressBookListComponent( is AddressBookRoute.List -> DefaultAddressBookListComponent(
appComponentContext = context, appComponentContext = context,
params = DefaultAddressBookListComponent.Params( params = DefaultAddressBookListComponent.Params(
mode = route.mode,
onContactClick = { clickIntents.onContactClick(ContactId(it)) }, onContactClick = { clickIntents.onContactClick(ContactId(it)) },
onAddContactClick = clickIntents::onAddContactClick, onAddContactClick = clickIntents::onAddContactClick,
), ),
addressSelectorFactory = addressSelectorFactory,
) )
is AddressBookRoute.EditContact -> DefaultEditContactComponent( is AddressBookRoute.EditContact -> DefaultEditContactComponent(
appComponentContext = context, appComponentContext = context,

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.common
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.MatchedContact
import kotlinx.collections.immutable.toImmutableList
/**
* Maps contacts to [MatchedContact]s for a given [networkId], keeping only those that have at least one address in that
* network (with just the matching entries). Name/address query filtering is done upstream by `GetContactsUseCase`.
*/
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 }
if (entries.isEmpty()) return@mapNotNull null
MatchedContact(
contactId = contact.id.value,
name = contact.name.value,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = contact.resolveIconColor(),
),
networkId = networkId,
entries = entries.map { entry ->
MatchedContact.ContactAddress(
address = entry.address,
memo = entry.memo,
networkName = entry.networkName,
)
}.toImmutableList(),
)
}
}
private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color =
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR
}

View file

@ -96,9 +96,12 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
} }
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) { private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List) AddressBookOpenMode.Default -> listOf(AddressBookRoute.List())
is AddressBookOpenMode.ContactSelection -> listOf(
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
)
is AddressBookOpenMode.WithContactCreation -> listOf( is AddressBookOpenMode.WithContactCreation -> listOf(
AddressBookRoute.List, AddressBookRoute.List(),
// Address + network are already known, so open the new contact with that address attached — no AddAddress. // 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,

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.common
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.SelectedContact
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* One-shot delivery of a contact picked on the full address-book list back to the Send flow.
*
* Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow
* is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) the picker can always close even if the
* collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear.
*/
@Singleton
internal class DefaultContactSelectionTrigger @Inject constructor() :
ContactSelectionTrigger,
ContactSelectionListener {
private val mutableResultFlow = MutableSharedFlow<SelectedContact>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val resultFlow: SharedFlow<SelectedContact> = mutableResultFlow
override fun trigger(contact: SelectedContact) {
mutableResultFlow.tryEmit(contact)
}
}

View file

@ -0,0 +1,65 @@
package com.tangem.features.addressbook.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.ds2.row.*
import com.tangem.core.ui.extensions.pluralStringResourceSafe
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
@Composable
internal fun ContactRow(contact: ContactUM) {
TangemRow(
onClick = contact.onClick,
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
startSlot = {
AccountIcon(
name = stringReference(contact.name),
icon = contact.icon,
size = AccountIconSize.Contact,
)
},
titleSlot = {
TangemRowText(
text = contact.name,
role = TangemRowTextRole.Title,
)
},
subtitleSlot = {
TangemRowText(
text = pluralStringResourceSafe(
R.plurals.address_book_addresses,
contact.networkAddressCount,
contact.networkAddressCount,
),
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_ContactRow() {
TangemThemePreviewRedesign {
ContactRow(
ContactUM(
id = "1",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = 1,
onClick = {},
),
)
}
}

View file

@ -1,7 +1,14 @@
package com.tangem.features.addressbook.di package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
import com.tangem.features.addressbook.common.DefaultAddressBookComponent import com.tangem.features.addressbook.common.DefaultAddressBookComponent
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -15,4 +22,24 @@ internal interface AddressBookComponentModule {
@Binds @Binds
@Singleton @Singleton
fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory
@Binds
@Singleton
fun bindContactsBlockComponentFactory(
factory: DefaultAddressBookContactsBlockComponent.Factory,
): AddressBookContactsBlockComponent.Factory
@Binds
@Singleton
fun bindAddressSelectorComponentFactory(
factory: DefaultAddressSelectorComponent.Factory,
): AddressSelectorComponent.Factory
@Binds
@Singleton
fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger
@Binds
@Singleton
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
} }

View file

@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.addaddress.model.AddAddressModel import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.block.model.ContactsBlockModel
import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds import dagger.Binds
@ -20,6 +21,11 @@ internal interface AddressBookModelModule {
@ClassKey(AddressBookListModel::class) @ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model fun bindAddressBookModel(model: AddressBookListModel): Model
@Binds
@IntoMap
@ClassKey(ContactsBlockModel::class)
fun bindContactsBlockModel(model: ContactsBlockModel): Model
@Binds @Binds
@IntoMap @IntoMap
@ClassKey(EditContactModel::class) @ClassKey(EditContactModel::class)

View file

@ -12,19 +12,17 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import javax.inject.Inject import javax.inject.Inject
@ModelScoped @ModelScoped
internal class EditContactStateController @Inject constructor() { internal class EditContactStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<EditContactUM> = MutableStateFlow(value = getInitialState()) val uiState: StateFlow<EditContactUM>
field = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<EditContactUM> get() = mutableUiState.asStateFlow()
fun update(transformer: Transformer<EditContactUM>) { fun update(transformer: Transformer<EditContactUM>) {
mutableUiState.update(function = transformer::transform) uiState.update(function = transformer::transform)
} }
private fun getInitialState(): EditContactUM { private fun getInitialState(): EditContactUM {

View file

@ -5,35 +5,72 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
import com.tangem.features.addressbook.list.ui.AddressBookListScreen
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
internal class DefaultAddressBookListComponent( internal class DefaultAddressBookListComponent(
appComponentContext: AppComponentContext, appComponentContext: AppComponentContext,
params: Params, params: Params,
addressSelectorFactory: AddressSelectorComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: AddressBookListModel = getOrCreateModel(params) private val model: AddressBookListModel = getOrCreateModel(params)
private val selectorSlot = childSlot(
source = model.selectorNavigation,
serializer = null,
key = "address_selector_slot",
handleBackButton = true,
childFactory = { contact, componentContext ->
addressSelectorFactory.create(
context = childByContext(componentContext),
params = AddressSelectorComponent.Params(
contact = contact,
onAddressSelected = model::deliverSelection,
onDismiss = { model.selectorNavigation.dismiss() },
),
)
},
)
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val selector by selectorSlot.subscribeAsState()
when (val addressBookListUM = state) { when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen( is AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = addressBookListUM.onAddClick, onAddContactClick = addressBookListUM.onAddClick,
onBackClick = router::pop, onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary), modifier = modifier.background(TangemTheme.colors3.bg.primary),
) )
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]") is AddressBookListUM.Content -> AddressBookListScreen(
state = addressBookListUM,
onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
} }
selector.child?.instance?.BottomSheet()
} }
/**
* @property mode Default (management) or Selector (pick a contact for a network)
* @property onContactClick management mode opens the contact editor (TODO [REDACTED_TASK_KEY])
* @property onAddContactClick opens the new-contact editor
*/
data class Params( data class Params(
val mode: AddressBookRoute.ListMode,
val onContactClick: (String) -> Unit, val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit, val onAddContactClick: () -> Unit,
) )

View file

@ -1,30 +1,92 @@
package com.tangem.features.addressbook.list.model package com.tangem.features.addressbook.list.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model 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.navigation.Router
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
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.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.UpdateAddressBookListInitialStateTransformer import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject 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.Selector]: pick a recipient for the given network a single matching address is
* returned right away, several open the address selector first.
*/
@ModelScoped @ModelScoped
internal class AddressBookListModel @Inject constructor( internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val stateController: AddressBookListStateController, private val stateController: AddressBookListStateController,
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
private val getContactsUseCase: GetContactsUseCase,
) : Model() { ) : Model() {
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>() private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
val state: StateFlow<AddressBookListUM> get() = stateController.uiState val state: StateFlow<AddressBookListUM> get() = stateController.uiState
/** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
val selectorNavigation = SlotNavigation<MatchedContact>()
init { init {
stateController.update( when (val mode = params.mode) {
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), // 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 fun observeSelectionContacts(networkId: String) {
getContactsUseCase(query = "")
.onEach { contacts ->
stateController.update(
UpdateAddressBookListSelectionStateTransformer(
matched = ContactMatcher.match(contacts = contacts, networkId = networkId),
onAddContactClick = params.onAddContactClick,
onContactClick = ::onPickContact,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onPickContact(contact: MatchedContact) {
val singleEntry = contact.entries.singleOrNull()
if (singleEntry != null) {
deliverSelection(contact.toSelectedContact(singleEntry))
} else {
selectorNavigation.activate(contact)
}
}
fun deliverSelection(contact: SelectedContact) {
contactSelectionTrigger.trigger(contact)
selectorNavigation.dismiss()
router.pop()
} }
} }

View file

@ -5,20 +5,17 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import javax.inject.Inject import javax.inject.Inject
@ModelScoped @ModelScoped
internal class AddressBookListStateController @Inject constructor() { internal class AddressBookListStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<AddressBookListUM> = val uiState: StateFlow<AddressBookListUM>
MutableStateFlow(value = getInitialState()) field = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<AddressBookListUM> get() = mutableUiState.asStateFlow()
fun update(transformer: Transformer<AddressBookListUM>) { fun update(transformer: Transformer<AddressBookListUM>) {
mutableUiState.update(function = transformer::transform) uiState.update(function = transformer::transform)
} }
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {}) private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})

View file

@ -1,21 +0,0 @@
package com.tangem.features.addressbook.list.state.converter
import com.tangem.domain.addressbook.model.Contact
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
/**
* Maps a domain [Contact] to its UI representation [ContactUM].
*
* TODO AddressBook ([REDACTED_TASK_KEY]): wire into [com.tangem.features.addressbook.list.model.AddressBookListModel] when the contacts list
* is loaded from the repository and the [com.tangem.features.addressbook.list.ui.state.AddressBookListUM.AddressList]
* screen is implemented.
*/
internal class ContactUMConverter : Converter<Contact, ContactUM> {
override fun convert(value: Contact): ContactUM = ContactUM(
id = value.id.value,
name = value.name.value,
addressCount = value.addressEntries.size,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.addressbook.list.state.transformers 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.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
/** /**
@ -13,7 +14,9 @@ internal class UpdateAddressBookListInitialStateTransformer(
override fun transform(prevState: AddressBookListUM): AddressBookListUM { override fun transform(prevState: AddressBookListUM): AddressBookListUM {
return when (prevState) { return when (prevState) {
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
is AddressBookListUM.AddressList -> prevState is AddressBookListUM.Content -> prevState.copy(
contentMode = ContentMode.Default(onAddClick = onAddContactClick),
)
} }
} }
} }

View file

@ -0,0 +1,38 @@
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,143 @@
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
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.extensions.resourceReference
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.features.addressbook.common.ui.ContactRow
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
@Composable
internal fun AddressBookListScreen(
state: AddressBookListUM.Content,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),
startContent = when (state.contentMode) {
is ContentMode.Default -> {
{
TangemButton(
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
}
}
ContentMode.Select -> null
},
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(
imageVector = when (state.contentMode) {
is ContentMode.Default -> Icons.ic_sign_plus_20
ContentMode.Select -> Icons.ic_cross_20
},
),
onClick = when (state.contentMode) {
is ContentMode.Default -> state.contentMode.onAddClick
ContentMode.Select -> onBackClick
},
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
LazyColumn(modifier = Modifier.padding(horizontal = 16.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 = {},
)
}
}
}

View file

@ -3,10 +3,26 @@ package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList 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.
*/
@Immutable @Immutable
internal sealed interface AddressBookListUM { internal sealed interface AddressBookListUM {
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
data class AddressList(val contacts: ImmutableList<ContactUM>) : AddressBookListUM data class Content(
val contacts: ImmutableList<ContactUM>,
val contentMode: ContentMode,
) : AddressBookListUM
}
@Immutable
internal sealed interface ContentMode {
data class Default(val onAddClick: () -> Unit) : ContentMode
data object Select : ContentMode
} }

View file

@ -1,11 +1,13 @@
package com.tangem.features.addressbook.list.ui.state package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
/** UI model of a single address-book contact row. Holds only what the list needs to render — no domain types. */
@Immutable @Immutable
internal data class ContactUM( internal data class ContactUM(
val id: String, val id: String,
val name: String, val name: String,
val addressCount: Int, val icon: AccountIconUM.CryptoPortfolio,
val networkAddressCount: Int,
val onClick: () -> Unit,
) )

View file

@ -5,14 +5,19 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
internal sealed class AddressBookRoute { internal sealed class AddressBookRoute {
/**
* The contacts list. [mode] mirrors the entry point: [ListMode.Default] for plain browsing/management, and
* [ListMode.Selector] when the list is opened to pick a contact for a given network a tap then returns the
* chosen address instead of opening the editor.
*/
@Serializable @Serializable
data object List : AddressBookRoute() data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute()
/** /**
* if [contactId] is not null we should fetch existing contact. * if [contactId] is not null we should fetch existing contact.
* *
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in * [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in
* [com.tangem.features.addressbook.entity.AddressBookOpenMode.WithContactCreation] mode the address and its * [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode the address and its
* network are already known, so the new contact is opened with that address already attached. * network are already known, so the new contact is opened with that address already attached.
*/ */
@Serializable @Serializable
@ -24,4 +29,16 @@ internal sealed class AddressBookRoute {
@Serializable @Serializable
data object AddAddress : AddressBookRoute() data object AddAddress : AddressBookRoute()
/** How the contacts list is shown — agnostic of which feature opened it. */
@Serializable
sealed interface ListMode {
@Serializable
data object Default : ListMode
/** Pick a contact that has an address in [networkId]. */
@Serializable
data class Selector(val networkId: String) : ListMode
}
} }

View file

@ -0,0 +1,100 @@
package com.tangem.features.addressbook.common
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Test
internal class ContactMatcherTest {
@Test
fun `GIVEN contacts WHEN match THEN keeps only contacts with an address in the network`() {
// Arrange
val ethContact = contact("Binance", entry("0xAAA", ETHEREUM), entry("Trx", TRON))
val tronOnly = contact("Tron Friend", entry("Trx2", TRON))
// Act
val result = ContactMatcher.match(listOf(ethContact, tronOnly), networkId = ETHEREUM)
// Assert
assertThat(result.map { it.name }).containsExactly("Binance")
assertThat(result.single().entries.map { it.address }).containsExactly("0xAAA")
}
@Test
fun `GIVEN no contact in the network WHEN match THEN returns empty`() {
// Arrange
val tronOnly = contact("Tron Friend", entry("Trx", TRON))
// Act
val result = ContactMatcher.match(listOf(tronOnly), networkId = ETHEREUM)
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN contact with multiple addresses in the network WHEN match THEN all those entries are returned`() {
// Arrange
val exchange = contact("Exchange", entry("0xAAA", ETHEREUM, memo = "1"), entry("0xBBB", ETHEREUM))
// Act
val result = ContactMatcher.match(listOf(exchange), networkId = ETHEREUM)
// Assert
val entries = result.single().entries
assertThat(entries.map { it.address }).containsExactly("0xAAA", "0xBBB")
assertThat(entries.first { it.address == "0xAAA" }.memo).isEqualTo("1")
}
@Test
fun `GIVEN contact with stored color WHEN match THEN avatar color is taken from the contact`() {
// Arrange
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "MexicanPink")
// Act
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
// Assert
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.MexicanPink)
}
@Test
fun `GIVEN contact with unknown color WHEN match THEN avatar color falls back to default`() {
// Arrange
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "not-a-color")
// Act
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
// Assert
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure)
}
private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact(
id = ContactId(name),
walletId = UserWalletId(stringValue = "0001"),
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
icon = "",
iconColor = iconColor,
createdAt = "2026-06-10T14:30:00.000Z",
updatedAt = "2026-06-10T14:30:00.000Z",
addressEntries = entries.toList(),
)
private fun entry(address: String, networkId: String, memo: String? = null): AddressEntry = AddressEntry(
id = AddressEntryId(address),
address = address,
networkId = Network.RawID(networkId),
memo = memo,
signature = "sig",
networkName = "Ethereum",
)
private companion object {
const val ETHEREUM = "ethereum"
const val TRON = "tron"
}
}