From bee4019e8b7f65f59a76833c6a76a15b70efc7e7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 13:35:52 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/common/routing/AppRoute.kt | 1 + .../routing/entity/AddressBookOpenMode.kt | 9 + .../tangem/common/ui/account/AccountIconUM.kt | 2 + core/res/src/main/res/values/strings.xml | 1 + .../core/ui/components/account/AccountIcon.kt | 9 +- .../addressbook/di/AddressBookDataModule.kt | 2 +- .../domain/addressbook/model/AddressEntry.kt | 1 + .../crypto/AddressBookCipherTest.kt | 1 + .../usecase/CreateContactUseCaseTest.kt | 15 +- .../usecase/GetContactsUseCaseTest.kt | 1 + .../usecase/GetVerifiedContactsUseCaseTest.kt | 9 +- .../usecase/SignAddressEntriesUseCaseTest.kt | 1 + .../usecase/UpdateContactUseCaseTest.kt | 2 + .../usecase/ValidateContactNameUseCaseTest.kt | 1 + .../VerifyAddressEntriesUseCaseTest.kt | 1 + features/address-book/api/build.gradle.kts | 4 + .../AddressBookContactsBlockComponent.kt | 33 ++++ .../addressbook/AddressSelectorComponent.kt | 19 +++ .../addressbook/ContactSelectionTrigger.kt | 20 +++ .../features/addressbook/MatchedContact.kt | 37 ++++ .../features/addressbook/SelectedContact.kt | 25 +++ .../state/AddAddressStateController.kt | 8 +- .../DefaultAddressSelectorComponent.kt | 34 ++++ .../ui/AddressSelectorBottomSheet.kt | 159 ++++++++++++++++++ ...efaultAddressBookContactsBlockComponent.kt | 36 ++++ .../block/model/ContactsBlockModel.kt | 50 ++++++ .../state/ContactsBlockStateController.kt | 20 +++ .../UpdateContactsBlockStateTransformer.kt | 41 +++++ .../addressbook/block/ui/ContactsBlock.kt | 101 +++++++++++ .../block/ui/state/ContactsBlockUM.kt | 18 ++ .../common/AddressBookChildFactory.kt | 9 +- .../addressbook/common/ContactMatcher.kt | 43 +++++ .../common/DefaultAddressBookComponent.kt | 7 +- .../common/DefaultContactSelectionTrigger.kt | 34 ++++ .../addressbook/common/ui/ContactRow.kt | 65 +++++++ .../di/AddressBookComponentModule.kt | 27 +++ .../addressbook/di/AddressBookModelModule.kt | 6 + .../state/EditContactStateController.kt | 8 +- .../list/DefaultAddressBookListComponent.kt | 39 ++++- .../list/model/AddressBookListModel.kt | 68 +++++++- .../state/AddressBookListStateController.kt | 9 +- .../state/converter/ContactUMConverter.kt | 21 --- ...eAddressBookListInitialStateTransformer.kt | 5 +- ...ddressBookListSelectionStateTransformer.kt | 38 +++++ .../list/ui/AddressBookListScreen.kt | 143 ++++++++++++++++ .../list/ui/state/AddressBookListUM.kt | 18 +- .../addressbook/list/ui/state/ContactUM.kt | 6 +- .../addressbook/route/AddressBookRoute.kt | 21 ++- .../addressbook/common/ContactMatcherTest.kt | 100 +++++++++++ 49 files changed, 1256 insertions(+), 72 deletions(-) create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt 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