diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt
index d71dbce1e9..90449eab97 100644
--- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt
+++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt
@@ -180,6 +180,7 @@ sealed class AppRoute(val path: String) : Route {
path = when (addressBookOpenMode) {
is AddressBookOpenMode.WithContactCreation ->
"/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}"
+ is AddressBookOpenMode.ContactSelection -> "/address_book/select/${addressBookOpenMode.networkId}"
AddressBookOpenMode.Default -> "/address_book"
},
)
diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt
index 7c6b6206c4..e9af666c27 100644
--- a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt
+++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt
@@ -14,4 +14,13 @@ sealed interface AddressBookOpenMode {
val address: String,
val networkId: String,
) : 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
}
\ No newline at end of file
diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt
index caa4e584e5..eccf95d9ec 100644
--- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt
+++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt
@@ -1,8 +1,10 @@
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.Icon
+@Immutable
sealed class AccountIconUM {
data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM()
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 3e4488d270..8277d57104 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -102,6 +102,7 @@
- %d address
- %d addresses
+ Choose address
Contact
Contact name
Copy address
diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt
index b9ffbb60e7..001c7069af 100644
--- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt
+++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt
@@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
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.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium
+ AccountIconSize.Contact -> TangemTheme.typography3.body.medium
}
val textSize by animateFloatAsState(
@@ -168,6 +169,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp
AccountIconSize.RedesignLarge -> 32.dp
+ AccountIconSize.Contact -> 20.dp
}
fun AccountIconSize.toBoxSize(): Dp = when (this) {
@@ -179,6 +181,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp
AccountIconSize.RedesignLarge -> 80.dp
+ AccountIconSize.Contact -> 40.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@@ -190,6 +193,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp
AccountIconSize.RedesignLarge -> 80.dp
+ AccountIconSize.Contact -> 100.dp
}
@Preview(showBackground = true)
@@ -233,7 +237,8 @@ private fun Sample() {
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge
- AccountIconSize.RedesignLarge -> AccountIconSize.Default
+ AccountIconSize.RedesignLarge -> AccountIconSize.Contact
+ AccountIconSize.Contact -> AccountIconSize.Default
}
}) { Text("Change") }
diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt
index 24ffcd6120..ce181b3417 100644
--- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt
+++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt
@@ -19,9 +19,9 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt
index 4ed3388f27..616eeebe2a 100644
--- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt
+++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt
@@ -9,6 +9,7 @@ data class AddressEntry(
val id: AddressEntryId,
val address: String,
val networkId: Network.RawID,
+ val networkName: String,
val memo: String?,
val signature: String,
)
\ No newline at end of file
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt
index b8cbeedb8d..faff49d72f 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt
@@ -259,6 +259,7 @@ internal class AddressBookCipherTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
+ networkName = "Ethereum",
)
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt
index 6059039b5e..cf1d9fba15 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt
@@ -5,23 +5,14 @@ import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
-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.*
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.SignHashesError
-import io.mockk.clearMocks
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.slot
+import io.mockk.*
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
@@ -57,6 +48,7 @@ class CreateContactUseCaseTest {
networkId = networkRawId,
memo = "memo",
signature = "sig",
+ networkName = "Ethereum",
),
)
@@ -165,6 +157,7 @@ class CreateContactUseCaseTest {
networkId = networkRawId,
memo = null,
signature = "sig",
+ networkName = "Ethereum",
),
),
)
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt
index a9f574f27d..21ce51f700 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt
@@ -101,6 +101,7 @@ class GetContactsUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
+ networkName = "Ethereum",
),
),
)
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt
index af4854afd1..9ea96b80b0 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt
@@ -3,13 +3,7 @@ package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
-import com.tangem.domain.addressbook.model.AddressEntriesVerification
-import com.tangem.domain.addressbook.model.AddressEntry
-import com.tangem.domain.addressbook.model.AddressEntryId
-import com.tangem.domain.addressbook.model.Contact
-import com.tangem.domain.addressbook.model.ContactId
-import com.tangem.domain.addressbook.model.ContactName
-import com.tangem.domain.addressbook.model.VerifiedContact
+import com.tangem.domain.addressbook.model.*
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@@ -103,6 +97,7 @@ class GetVerifiedContactsUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig-$id",
+ networkName = "Ethereum",
)
private fun contact(name: String, entries: List): Contact = Contact(
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt
index db38425d7f..c4db011355 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt
@@ -137,6 +137,7 @@ class SignAddressEntriesUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
+ networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt
index 46d68f096e..6fb2cfe6aa 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt
@@ -54,6 +54,7 @@ class UpdateContactUseCaseTest {
networkId = networkRawId,
memo = "memo",
signature = "sig2",
+ networkName = "Ethereum",
),
)
@@ -137,6 +138,7 @@ class UpdateContactUseCaseTest {
networkId = networkRawId,
memo = null,
signature = "sig",
+ networkName = "Ethereum",
),
),
)
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt
index 9b51b6df7d..bcc412f11e 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt
@@ -75,6 +75,7 @@ class ValidateContactNameUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
+ networkName = "Ethereum",
),
),
)
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt
index f5b8ba5afb..04821e35ac 100644
--- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt
@@ -165,6 +165,7 @@ class VerifyAddressEntriesUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = signature,
+ networkName = "Ethereum",
)
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts
index 50ede12ea4..b70fe75e59 100644
--- a/features/address-book/api/build.gradle.kts
+++ b/features/address-book/api/build.gradle.kts
@@ -12,6 +12,7 @@ dependencies {
/* Project - Common */
api(projects.common.routing)
+ implementation(projects.common.ui)
/* Project - Domain */
implementation(projects.domain.models)
@@ -22,4 +23,7 @@ dependencies {
/* Compose */
implementation(deps.compose.runtime)
+
+ /** Other */
+ implementation(deps.kotlin.immutable.collections)
}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt
new file mode 100644
index 0000000000..7c8f04d753
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt
@@ -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
+
+ /**
+ * @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,
+ val onContactClick: (MatchedContact) -> Unit,
+ val onSeeAllClick: () -> Unit,
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt
new file mode 100644
index 0000000000..4f0eb88c45
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt
@@ -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
+
+ data class Params(
+ val contact: MatchedContact,
+ val onAddressSelected: (SelectedContact) -> Unit,
+ val onDismiss: () -> Unit,
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt
new file mode 100644
index 0000000000..b11d5dedad
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt
@@ -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
+}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt
new file mode 100644
index 0000000000..f5d9b0adff
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt
@@ -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,
+) {
+
+ /** 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,
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt
new file mode 100644
index 0000000000..36e8e8cd74
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt
@@ -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?,
+)
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt
index d5ecedc0de..b0f71b411c 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt
@@ -11,19 +11,17 @@ import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class AddAddressStateController @Inject constructor() {
- private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState())
-
- val uiState: StateFlow get() = mutableUiState.asStateFlow()
+ val uiState: StateFlow
+ field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer) {
- mutableUiState.update(function = transformer::transform)
+ uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt
new file mode 100644
index 0000000000..6b94731ad1
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt
new file mode 100644
index 0000000000..cd305175a2
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt
@@ -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(
+ 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 = {},
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt
new file mode 100644
index 0000000000..c189ace95c
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt
new file mode 100644
index 0000000000..1aa457aced
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt
@@ -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()
+
+ val state: StateFlow 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)
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt
new file mode 100644
index 0000000000..a0367bbd82
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt
@@ -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
+ field = MutableStateFlow(value = ContactsBlockUM.Hidden)
+
+ fun update(transformer: Transformer) {
+ uiState.update(function = transformer::transform)
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt
new file mode 100644
index 0000000000..c267bfd65d
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt
@@ -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,
+ private val onSeeAllClick: () -> Unit,
+ private val onContactClick: (MatchedContact) -> Unit,
+) : Transformer {
+
+ 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
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt
new file mode 100644
index 0000000000..ca1526a0a5
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt
@@ -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,
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt
new file mode 100644
index 0000000000..acf334bbb9
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt
@@ -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,
+ val onSeeAllClick: () -> Unit,
+ ) : ContactsBlockUM
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt
index 3283d11e76..d07a64c7f5 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt
@@ -3,6 +3,7 @@ package com.tangem.features.addressbook.common
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
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.editcontact.DefaultEditContactComponent
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
* 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(
route: AddressBookRoute,
context: AppComponentContext,
clickIntents: AddressBookClickIntents,
): ComposableContentComponent = when (route) {
- AddressBookRoute.List -> DefaultAddressBookListComponent(
+ is AddressBookRoute.List -> DefaultAddressBookListComponent(
appComponentContext = context,
params = DefaultAddressBookListComponent.Params(
+ mode = route.mode,
onContactClick = { clickIntents.onContactClick(ContactId(it)) },
onAddContactClick = clickIntents::onAddContactClick,
),
+ addressSelectorFactory = addressSelectorFactory,
)
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
appComponentContext = context,
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt
new file mode 100644
index 0000000000..c044f8e480
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt
@@ -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, networkId: String): List {
+ 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
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt
index 462aec3631..5066885b29 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt
@@ -96,9 +96,12 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
}
private fun initialStack(): List = 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(
- AddressBookRoute.List,
+ AddressBookRoute.List(),
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
AddressBookRoute.EditContact(
predefinedAddress = mode.address,
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt
new file mode 100644
index 0000000000..115b418695
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt
@@ -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(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ override val resultFlow: SharedFlow = mutableResultFlow
+
+ override fun trigger(contact: SelectedContact) {
+ mutableResultFlow.tryEmit(contact)
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt
new file mode 100644
index 0000000000..20855e8bb0
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt
@@ -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 = {},
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
index 868ed604e7..30378a73a6 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
@@ -1,7 +1,14 @@
package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent
+import com.tangem.features.addressbook.AddressBookContactsBlockComponent
+import com.tangem.features.addressbook.AddressSelectorComponent
+import com.tangem.features.addressbook.ContactSelectionListener
+import com.tangem.features.addressbook.ContactSelectionTrigger
+import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
+import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
+import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -15,4 +22,24 @@ internal interface AddressBookComponentModule {
@Binds
@Singleton
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
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
index 8d81fd327a..9b21153da9 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
@@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
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.editcontact.model.EditContactModel
import dagger.Binds
@@ -20,6 +21,11 @@ internal interface AddressBookModelModule {
@ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model
+ @Binds
+ @IntoMap
+ @ClassKey(ContactsBlockModel::class)
+ fun bindContactsBlockModel(model: ContactsBlockModel): Model
+
@Binds
@IntoMap
@ClassKey(EditContactModel::class)
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt
index 566148a41d..bd75ec37da 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt
@@ -12,19 +12,17 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EditContactStateController @Inject constructor() {
- private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState())
-
- val uiState: StateFlow get() = mutableUiState.asStateFlow()
+ val uiState: StateFlow
+ field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer) {
- mutableUiState.update(function = transformer::transform)
+ uiState.update(function = transformer::transform)
}
private fun getInitialState(): EditContactUM {
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt
index 6214fdbc75..4f02d5303e 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt
@@ -5,35 +5,72 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
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.ui.AddressBookEmptyScreen
+import com.tangem.features.addressbook.list.ui.AddressBookListScreen
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
+import com.tangem.features.addressbook.route.AddressBookRoute
internal class DefaultAddressBookListComponent(
appComponentContext: AppComponentContext,
params: Params,
+ addressSelectorFactory: AddressSelectorComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
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
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
+ val selector by selectorSlot.subscribeAsState()
when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = addressBookListUM.onAddClick,
onBackClick = router::pop,
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(
+ val mode: AddressBookRoute.ListMode,
val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit,
)
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt
index 6d269a7616..e53a5d03d8 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt
@@ -1,30 +1,92 @@
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.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.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.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 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
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: AddressBookListStateController,
+ private val router: Router,
+ private val contactSelectionTrigger: ContactSelectionTrigger,
+ private val getContactsUseCase: GetContactsUseCase,
) : Model() {
private val params = paramsContainer.require()
val state: StateFlow get() = stateController.uiState
+ /** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
+ val selectorNavigation = SlotNavigation()
+
init {
- stateController.update(
- UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
- )
+ 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 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()
}
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt
index f42ef0cd71..8b7799c8f9 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt
@@ -5,20 +5,17 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class AddressBookListStateController @Inject constructor() {
- private val mutableUiState: MutableStateFlow =
- MutableStateFlow(value = getInitialState())
-
- val uiState: StateFlow get() = mutableUiState.asStateFlow()
+ val uiState: StateFlow
+ field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer) {
- mutableUiState.update(function = transformer::transform)
+ uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt
deleted file mode 100644
index e50783cb34..0000000000
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt
+++ /dev/null
@@ -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 {
-
- override fun convert(value: Contact): ContactUM = ContactUM(
- id = value.id.value,
- name = value.name.value,
- addressCount = value.addressEntries.size,
- )
-}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt
index 8191d0f1b5..db17c8e2e3 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt
@@ -1,6 +1,7 @@
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
/**
@@ -13,7 +14,9 @@ internal class UpdateAddressBookListInitialStateTransformer(
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
return when (prevState) {
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
- is AddressBookListUM.AddressList -> prevState
+ is AddressBookListUM.Content -> prevState.copy(
+ contentMode = ContentMode.Default(onAddClick = onAddContactClick),
+ )
}
}
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt
new file mode 100644
index 0000000000..fb09fff616
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt
@@ -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,
+ private val onAddContactClick: () -> Unit,
+ private val onContactClick: (MatchedContact) -> Unit,
+) : Transformer {
+
+ 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) },
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt
new file mode 100644
index 0000000000..b338c678e9
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt
@@ -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 = {},
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt
index e1b9061014..f8d7481037 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt
@@ -3,10 +3,26 @@ package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
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
internal sealed interface AddressBookListUM {
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
- data class AddressList(val contacts: ImmutableList) : AddressBookListUM
+ data class Content(
+ val contacts: ImmutableList,
+ val contentMode: ContentMode,
+ ) : AddressBookListUM
+}
+
+@Immutable
+internal sealed interface ContentMode {
+
+ data class Default(val onAddClick: () -> Unit) : ContentMode
+
+ data object Select : ContentMode
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt
index c5f526a6bc..d15a700047 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt
@@ -1,11 +1,13 @@
package com.tangem.features.addressbook.list.ui.state
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
internal data class ContactUM(
val id: String,
val name: String,
- val addressCount: Int,
+ val icon: AccountIconUM.CryptoPortfolio,
+ val networkAddressCount: Int,
+ val onClick: () -> Unit,
)
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt
index c59c929b70..e0a39904b6 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt
@@ -5,14 +5,19 @@ import kotlinx.serialization.Serializable
@Serializable
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
- data object List : AddressBookRoute()
+ data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact.
*
* [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.
*/
@Serializable
@@ -24,4 +29,16 @@ internal sealed class AddressBookRoute {
@Serializable
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
+ }
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt
new file mode 100644
index 0000000000..2f9974771e
--- /dev/null
+++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt
@@ -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"
+ }
+}
\ No newline at end of file