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 index 7c8f04d753..23058e9d1d 100644 --- 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 @@ -3,7 +3,6 @@ package com.tangem.features.addressbook import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.StateFlow /** @@ -16,7 +15,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent { interface Factory : ComponentFactory /** - * @property userWalletId the sending wallet whose address book is shown * @property network the current send network; only contacts with an address in this network are shown * @property queryFlow the live recipient-input text used to filter the block * @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides @@ -24,7 +22,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent { * @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode */ data class Params( - val userWalletId: UserWalletId, val network: Network, val queryFlow: StateFlow, val onContactClick: (MatchedContact) -> Unit, 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 index f5d9b0adff..d2b0031a27 100644 --- 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 @@ -12,6 +12,7 @@ import kotlinx.collections.immutable.ImmutableList data class MatchedContact( val contactId: String, + val walletId: String, val name: String, val icon: AccountIconUM.CryptoPortfolio, val networkId: String, diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 2869945728..6cf352ac01 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.addressBook) implementation(projects.domain.models) + implementation(projects.domain.wallets) /** Common */ implementation(projects.common.ui) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt index 6a8d9f5cfe..63f24efd33 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -3,7 +3,9 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,26 +46,44 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie ) }, ) - - RecipientRow( - modifier = Modifier.padding(horizontal = 16.dp), - addressField = state.addressField, - onValueChange = state.onAddressChange, - onAddressClear = state.onAddressClear, - onQrClick = state.onQrClick, - onPasteClick = state.onPasteClick, - ) - SpacerH(20.dp) - NetworkBlock( + BoxWithConstraints( modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary), - chosenNetworkStateUM = state.chosenNetworkStateUM, - onNetworkSelectClick = state.onNetworkClick, - ) - PrimaryButton(state.buttonUM) + .weight(1f), + ) { + val minContentHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Column( + modifier = Modifier + .heightIn(min = minContentHeight) + .imePadding(), + ) { + RecipientRow( + modifier = Modifier.padding(horizontal = 16.dp), + addressField = state.addressField, + onValueChange = state.onAddressChange, + onAddressClear = state.onAddressClear, + onQrClick = state.onQrClick, + onPasteClick = state.onPasteClick, + ) + SpacerH(20.dp) + NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = state.chosenNetworkStateUM, + onNetworkSelectClick = state.onNetworkClick, + ) + PrimaryButton(state.buttonUM) + } + } + } } } 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 index cd305175a2..60ea57de1e 100644 --- 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 @@ -134,6 +134,7 @@ private fun Preview_AddressSelectorList() { AddressSelectorList( contact = MatchedContact( contactId = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, 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 index 1aa457aced..3ad200dc0a 100644 --- 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 @@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.block.state.ContactsBlockStateController import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer @@ -11,11 +12,7 @@ import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -25,6 +22,7 @@ internal class ContactsBlockModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val stateController: ContactsBlockStateController, getContactsUseCase: GetContactsUseCase, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -32,13 +30,19 @@ internal class ContactsBlockModel @Inject constructor( val state: StateFlow get() = stateController.uiState init { - params.queryFlow - .flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) } - .onEach { contacts -> + combine( + params.queryFlow.flatMapLatest { query -> + getContactsUseCase(query = query, userWalletId = null) + }, + getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), + ) { contacts, wallets -> contacts to wallets.values.toList() } + .onEach { (contacts, wallets) -> val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId) stateController.update( UpdateContactsBlockStateTransformer( matched = matched, + walletNamesById = wallets.associate { it.walletId.stringValue to it.name }, + shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1, onSeeAllClick = params.onSeeAllClick, onContactClick = params.onContactClick, ), 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 index c267bfd65d..bf9e47bdd4 100644 --- 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 @@ -9,6 +9,8 @@ import kotlinx.collections.immutable.toImmutableList /** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */ internal class UpdateContactsBlockStateTransformer( private val matched: List, + private val walletNamesById: Map, + private val shouldShowWalletName: Boolean, private val onSeeAllClick: () -> Unit, private val onContactClick: (MatchedContact) -> Unit, ) : Transformer { @@ -29,9 +31,11 @@ internal class UpdateContactsBlockStateTransformer( private fun MatchedContact.toRowUM(): ContactUM = ContactUM( id = contactId, + walletId = walletId, name = name, icon = icon, networkAddressCount = entries.size, + walletName = if (shouldShowWalletName) walletNamesById[walletId] else null, onClick = { onContactClick(this) }, ) 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 index ca1526a0a5..d9d1366454 100644 --- 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 @@ -74,6 +74,7 @@ private fun Preview_ContactsBlock() { contacts = persistentListOf( ContactUM( id = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, @@ -84,6 +85,7 @@ private fun Preview_ContactsBlock() { ), ContactUM( id = "2", + walletId = "01", name = "Alice", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, 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 index c044f8e480..b2579dec4e 100644 --- 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 @@ -12,8 +12,6 @@ import kotlinx.collections.immutable.toImmutableList */ 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 } @@ -21,11 +19,9 @@ internal object ContactMatcher { MatchedContact( contactId = contact.id.value, + walletId = contact.walletId.stringValue, name = contact.name.value, - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = contact.resolveIconColor(), - ), + icon = contact.toAvatarIcon(), networkId = networkId, entries = entries.map { entry -> MatchedContact.ContactAddress( @@ -38,6 +34,10 @@ internal object ContactMatcher { } } - private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color = - CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR + /** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */ + private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } + ?: CryptoPortfolioIcon.Color.Azure, + ) } \ 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 index 20855e8bb0..d154a91c57 100644 --- 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 @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.StringsSigns @Composable internal fun ContactRow(contact: ContactUM) { @@ -33,12 +34,14 @@ internal fun ContactRow(contact: ContactUM) { ) }, subtitleSlot = { + val addresses = pluralStringResourceSafe( + R.plurals.address_book_addresses, + contact.networkAddressCount, + contact.networkAddressCount, + ) TangemRowText( - text = pluralStringResourceSafe( - R.plurals.address_book_addresses, - contact.networkAddressCount, - contact.networkAddressCount, - ), + text = contact.walletName?.let { walletName -> "$addresses ${StringsSigns.DOT} $walletName" } + ?: addresses, role = TangemRowTextRole.Subtitle, ) }, @@ -52,11 +55,13 @@ private fun Preview_ContactRow() { ContactRow( ContactUM( id = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, color = CryptoPortfolioIcon.Color.Azure, ), + walletName = "Wallet 1", networkAddressCount = 1, onClick = {}, ), diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt index c45223210c..4334ba0f66 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -49,6 +49,7 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif modifier = modifier .fillMaxSize() .background(color = TangemTheme.colors3.bg.primary) + .imePadding() .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -216,7 +217,7 @@ private fun ContactColor(colors: EditContactUM.Colors) { FlowRow( maxItemsInEachRow = 6, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(18.dp), ) { colors.list.fastForEach { color -> 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 e53a5d03d8..0f2cfb6a66 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 @@ -7,31 +7,33 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.ContactSelectionTrigger import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.SelectedContact -import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.state.AddressBookListStateController -import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer -import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import javax.inject.Inject /** * Backs the contacts list. The list content is the same however the address book was opened — the open * [AddressBookRoute.ListMode] only decides what tapping a contact does: - * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]). + * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]). * - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is * returned right away, several open the address selector first. */ +@Suppress("LongParameterList", "NamedArguments") +@OptIn(ExperimentalCoroutinesApi::class) @ModelScoped internal class AddressBookListModel @Inject constructor( paramsContainer: ParamsContainer, @@ -39,7 +41,8 @@ internal class AddressBookListModel @Inject constructor( private val stateController: AddressBookListStateController, private val router: Router, private val contactSelectionTrigger: ContactSelectionTrigger, - private val getContactsUseCase: GetContactsUseCase, + getVerifiedContactsInteractor: GetVerifiedContactsInteractor, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -49,32 +52,77 @@ internal class AddressBookListModel @Inject constructor( /** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */ val selectorNavigation = SlotNavigation() - init { - when (val mode = params.mode) { - // Browse/manage: full list UI is TODO [REDACTED_TASK_KEY]. - AddressBookRoute.ListMode.Default -> stateController.update( - UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), - ) - // Pick a recipient: same list, the tap returns the chosen address. - is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId) - } - } + private val searchQuery = MutableStateFlow(value = "") + private val searchActive = MutableStateFlow(value = false) + private val selectedWalletId = MutableStateFlow(value = null) - private fun observeSelectionContacts(networkId: String) { - getContactsUseCase(query = "") - .onEach { contacts -> - stateController.update( - UpdateAddressBookListSelectionStateTransformer( - matched = ContactMatcher.match(contacts = contacts, networkId = networkId), - onAddContactClick = params.onAddContactClick, - onContactClick = ::onPickContact, - ), - ) - } + private val allContacts: SharedFlow> = + getVerifiedContactsInteractor(query = "", userWalletId = null) + .shareIn(modelScope, SharingStarted.Lazily, replay = 1) + + init { + val matchedContacts = searchQuery.flatMapLatest { query -> + if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null) + } + combine( + allContacts, + matchedContacts, + searchQuery, + combine(selectedWalletId, searchActive) { selected, active -> selected to active }, + getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), + ) { all, matched, query, (selected, active), wallets -> + ListInputs( + allContacts = all, + matchedContacts = matched, + query = query, + selectedWalletId = selected, + isSearchActive = active, + wallets = wallets, + ) + } + .onEach(::updateState) .flowOn(dispatchers.default) .launchIn(modelScope) } + private fun updateState(inputs: ListInputs) { + stateController.update( + UpdateAddressBookListContentTransformer( + allContacts = inputs.allContacts, + matchedContacts = inputs.matchedContacts, + mode = params.mode, + wallets = inputs.wallets, + selectedWalletId = inputs.selectedWalletId, + query = inputs.query, + isSearchActive = inputs.isSearchActive, + onContactClick = params.onContactClick, + onPickContact = ::onPickContact, + onQueryChange = ::onQueryChange, + onActiveChange = ::onActiveChange, + onClearQuery = ::onClearQuery, + onChipSelected = ::onChipSelected, + onAddContactClick = params.onAddContactClick, + ), + ) + } + + private fun onQueryChange(query: String) { + searchQuery.value = query + } + + private fun onActiveChange(active: Boolean) { + searchActive.value = active + } + + private fun onClearQuery() { + searchQuery.value = "" + } + + private fun onChipSelected(walletId: String?) { + if (selectedWalletId.value == walletId) return + selectedWalletId.value = walletId + } + private fun onPickContact(contact: MatchedContact) { val singleEntry = contact.entries.singleOrNull() if (singleEntry != null) { @@ -89,4 +137,13 @@ internal class AddressBookListModel @Inject constructor( selectorNavigation.dismiss() router.pop() } + + private data class ListInputs( + val allContacts: List, + val matchedContacts: List, + val query: String, + val selectedWalletId: String?, + val isSearchActive: Boolean, + val wallets: Map, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt new file mode 100644 index 0000000000..c6eeaaf47d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt @@ -0,0 +1,129 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.common.ContactMatcher +import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter +import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import com.tangem.features.addressbook.route.AddressBookRoute +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +internal class UpdateAddressBookListContentTransformer( + wallets: Map, + private val allContacts: List, + private val matchedContacts: List, + private val mode: AddressBookRoute.ListMode, + private val selectedWalletId: String?, + private val query: String, + private val isSearchActive: Boolean, + private val onContactClick: (String) -> Unit, + private val onPickContact: (MatchedContact) -> Unit, + private val onQueryChange: (String) -> Unit, + private val onActiveChange: (Boolean) -> Unit, + private val onClearQuery: () -> Unit, + private val onChipSelected: (String?) -> Unit, + private val onAddContactClick: () -> Unit, +) : Transformer { + + private val orderedWalletIds: List = wallets.values.map { it.walletId.stringValue } + private val walletNamesById: Map = wallets.values.associate { it.walletId.stringValue to it.name } + + override fun transform(prevState: AddressBookListUM): AddressBookListUM { + val matchedItems = matchedItems() + + if (matchedItems.isEmpty() && query.isBlank()) { + return AddressBookListUM.Empty(onAddClick = onAddContactClick) + } + + val matchingWalletIds = matchedItems.map { it.walletId }.distinct() + val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds } + val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty() + + // On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to. + val shouldShowWalletName = areChipsVisible && effectiveSelected == null + + val displayContacts = matchedItems + .filter { effectiveSelected == null || it.walletId == effectiveSelected } + .map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it } + .toImmutableList() + + return AddressBookListUM.Content( + searchBar = buildSearchBar(), + chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(), + contacts = displayContacts, + isNothingFound = matchedItems.isEmpty(), + contentMode = contentMode(), + ) + } + + private fun matchedItems(): List = when (val mode = mode) { + AddressBookRoute.ListMode.Default -> + DefaultContactConverter(onContactClick).convertList(matchedContacts) + is AddressBookRoute.ListMode.Selector -> + SelectorContactConverter(onPickContact) + .convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId)) + } + + /** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */ + private fun totalWalletIds(): Set = when (val mode = mode) { + AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue } + is AddressBookRoute.ListMode.Selector -> + ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId } + } + + private fun contentMode(): ContentMode = when (mode) { + AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick) + is AddressBookRoute.ListMode.Selector -> ContentMode.Select + } + + private fun buildSearchBar(): TangemSearch.State = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = query, + onQueryChange = onQueryChange, + isActive = isSearchActive, + onActiveChange = onActiveChange, + onClearClick = onClearQuery, + onCloseClick = { onActiveChange(false) }, + ) + + private fun buildChips(matchingWalletIds: List, effectiveSelected: String?) = buildList { + add( + AddressBookChipUM( + id = ALL_CHIP_ID, + text = resourceReference(R.string.common_all), + isSelected = effectiveSelected == null, + onClick = { onChipSelected(null) }, + ), + ) + orderedWalletIds + .filter { it in matchingWalletIds } + .forEach { walletId -> + add( + AddressBookChipUM( + id = walletId, + text = stringReference(walletNamesById[walletId] ?: walletId), + isSelected = walletId == effectiveSelected, + onClick = { onChipSelected(walletId) }, + iconRes = R.drawable.ic_key_card_20, + ), + ) + } + }.toImmutableList() + + private companion object { + const val ALL_CHIP_ID = "all" + } +} \ 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 deleted file mode 100644 index db17c8e2e3..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.addressbook.list.state.transformers - -import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -import com.tangem.features.addressbook.list.ui.state.ContentMode -import com.tangem.utils.transformer.Transformer - -/** - * Wires the "add contact" callback owned by the container into the initial (empty) list state. - */ -internal class UpdateAddressBookListInitialStateTransformer( - private val onAddContactClick: () -> Unit, -) : Transformer { - - override fun transform(prevState: AddressBookListUM): AddressBookListUM { - return when (prevState) { - is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) - is AddressBookListUM.Content -> prevState.copy( - contentMode = ContentMode.Default(onAddClick = onAddContactClick), - ) - } - } -} \ 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 deleted file mode 100644 index fb09fff616..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.features.addressbook.list.state.transformers - -import com.tangem.features.addressbook.MatchedContact -import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -import com.tangem.features.addressbook.list.ui.state.ContactUM -import com.tangem.features.addressbook.list.ui.state.ContentMode -import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.toImmutableList - -/** - * Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the - * user can still add a contact. - */ -internal class UpdateAddressBookListSelectionStateTransformer( - private val matched: List, - 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/state/transformers/converter/DefaultContactConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt new file mode 100644 index 0000000000..5ce1adc36a --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.features.addressbook.list.state.transformers.converter + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +internal class DefaultContactConverter( + private val onContactClick: (String) -> Unit, +) : Converter { + + override fun convert(value: VerifiedContact): ContactUM { + val contact = value.contact + val name = contact.name.value + return ContactUM( + id = contact.id.value, + walletId = contact.walletId.stringValue, + name = name, + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon } + ?: CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor } + ?: CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = contact.addressEntries.size, + onClick = { onContactClick(contact.id.value) }, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt new file mode 100644 index 0000000000..15267b5089 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook.list.state.transformers.converter + +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +internal class SelectorContactConverter( + private val onPickContact: (MatchedContact) -> Unit, +) : Converter { + + override fun convert(value: MatchedContact): ContactUM = ContactUM( + id = value.contactId, + walletId = value.walletId, + name = value.name, + icon = value.icon, + networkAddressCount = value.entries.size, + onClick = { onPickContact(value) }, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt new file mode 100644 index 0000000000..e760fd3383 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt @@ -0,0 +1,68 @@ +package com.tangem.features.addressbook.list.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM + +@Composable +internal fun AddressBookChip(state: AddressBookChipUM, modifier: Modifier = Modifier) { + val backgroundColor = if (state.isSelected) { + TangemTheme.colors2.tabs.backgroundPrimary + } else { + TangemTheme.colors2.tabs.backgroundSecondary + } + val textColor = if (state.isSelected) { + TangemTheme.colors2.tabs.textPrimary + } else { + TangemTheme.colors2.tabs.textSecondary + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(shape = CircleShape) + .background(color = backgroundColor) + .selectable( + selected = state.isSelected, + onClick = state.onClick, + role = Role.Tab, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = state.text.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = textColor, + maxLines = 1, + ) + if (state.iconRes != null) { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(20.dp), + painter = painterResource(id = state.iconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + ) + } + } +} \ 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 index b338c678e9..e999fdeac3 100644 --- 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 @@ -1,32 +1,38 @@ package com.tangem.features.addressbook.list.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.R import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.search.TangemSearch import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 -import com.tangem.core.ui.res.generated.icons.ic_cross_20 -import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 -import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.core.ui.res.generated.icons.* import com.tangem.features.addressbook.common.ui.ContactRow +import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider +import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.list.ui.state.ContactUM import com.tangem.features.addressbook.list.ui.state.ContentMode -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.ImmutableList @Composable internal fun AddressBookListScreen( @@ -34,7 +40,7 @@ internal fun AddressBookListScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { + Column(modifier = modifier.navigationBarsPadding()) { TangemTopBar( modifier = Modifier.statusBarsPadding(), title = resourceReference(R.string.address_book_title), @@ -68,76 +74,93 @@ internal fun AddressBookListScreen( ) }, ) - LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { - items(items = state.contacts, key = ContactUM::id) { contact -> - ContactRow(contact = contact) + + TangemSearch( + state = state.searchBar, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + if (state.chips.isNotEmpty()) { + WalletChips(chips = state.chips) + } + + if (state.isNothingFound) { + NothingFoundContent() + } else { + LazyColumn( + modifier = Modifier + .imePadding() + .padding(top = 16.dp) + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(24.dp), + ), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 12.dp, + ), + ) { + items(items = state.contacts, key = ContactUM::id) { contact -> + ContactRow(contact = contact) + } } } } } @Composable -@Preview(showBackground = true, widthDp = 360) -private fun Preview_AddressBookListScreen() { - TangemThemePreviewRedesign { - Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { - AddressBookListScreen( - state = AddressBookListUM.Content( - contacts = persistentListOf( - ContactUM( - id = "1", - name = "Binance", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.Azure, - ), - networkAddressCount = 1, - onClick = {}, - ), - ContactUM( - id = "2", - name = "Alice", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.UFOGreen, - ), - networkAddressCount = 3, - onClick = {}, - ), - ), - contentMode = ContentMode.Default(onAddClick = {}), - ), - onBackClick = {}, - ) - - AddressBookListScreen( - state = AddressBookListUM.Content( - contacts = persistentListOf( - ContactUM( - id = "1", - name = "Binance", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.Azure, - ), - networkAddressCount = 1, - onClick = {}, - ), - ContactUM( - id = "2", - name = "Alice", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.UFOGreen, - ), - networkAddressCount = 3, - onClick = {}, - ), - ), - contentMode = ContentMode.Select, - ), - onBackClick = {}, - ) +private fun WalletChips(chips: ImmutableList) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = chips, key = AddressBookChipUM::id) { chip -> + AddressBookChip(state = chip) } } +} + +@Composable +private fun ColumnScope.NothingFoundContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(80.dp) + .background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.ic_search_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + modifier = Modifier.size(28.dp), + ) + } + Text( + modifier = Modifier.padding(top = 32.dp), + text = stringResourceSafe(R.string.common_no_results), + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.small, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +private fun Preview_AddressBookListScreen( + @PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario, +) { + TangemThemePreviewRedesign { + AddressBookListScreen( + state = scenario.state, + onBackClick = {}, + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt new file mode 100644 index 0000000000..d018f9d910 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt @@ -0,0 +1,106 @@ +package com.tangem.features.addressbook.list.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import kotlinx.collections.immutable.persistentListOf + +internal data class AddressBookListPreviewScenario( + val title: String, + val state: AddressBookListUM.Content, +) + +internal object AddressBookListPreviewFixtures { + + private val scenarioDefaultWithContacts = AddressBookListPreviewScenario( + title = "Default – chips and contacts", + state = AddressBookListUM.Content( + searchBar = searchBar(query = ""), + chips = persistentListOf( + AddressBookChipUM( + id = "all", + text = resourceReference(R.string.common_all), + isSelected = true, + onClick = {}, + ), + AddressBookChipUM( + id = "00", + text = stringReference("Wallet 1"), + isSelected = false, + onClick = {}, + iconRes = R.drawable.ic_key_card_20, + ), + AddressBookChipUM( + id = "01", + text = stringReference("Wallet 2"), + isSelected = false, + onClick = {}, + iconRes = R.drawable.ic_key_card_20, + ), + ), + contacts = persistentListOf( + contact( + walletId = "00", + name = "Binance", + color = CryptoPortfolioIcon.Color.Azure, + count = 1, + ), + contact( + walletId = "01", + name = "Alice", + color = CryptoPortfolioIcon.Color.UFOGreen, + count = 3, + ), + ), + isNothingFound = false, + contentMode = ContentMode.Default(onAddClick = {}), + ), + ) + + val scenarioSelectNothingFound = AddressBookListPreviewScenario( + title = "Select – nothing found", + state = AddressBookListUM.Content( + searchBar = searchBar(query = "Antonio"), + chips = persistentListOf(), + contacts = persistentListOf(), + isNothingFound = true, + contentMode = ContentMode.Select, + ), + ) + + fun allScenarios(): List = listOf( + scenarioDefaultWithContacts, + scenarioSelectNothingFound, + ) + + private fun searchBar(query: String) = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = query, + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ) + + private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM( + id = name + walletId, + walletId = walletId, + name = name, + icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color), + networkAddressCount = count, + onClick = {}, + ) +} + +/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ +internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = AddressBookListPreviewFixtures.allScenarios().asSequence() +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt new file mode 100644 index 0000000000..22addbac27 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class AddressBookChipUM( + val id: String, + val text: TextReference, + val isSelected: Boolean, + val onClick: () -> Unit, + @DrawableRes val iconRes: Int? = null, +) \ 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 f8d7481037..08a4cbdfe2 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 @@ -1,20 +1,31 @@ package com.tangem.features.addressbook.list.ui.state import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds2.search.TangemSearch import kotlinx.collections.immutable.ImmutableList /** * UI state of the contacts list. The list itself is the same however the address book was opened — it is either - * [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a - * contact tap does, which is captured by [ContactUM.onClick], not by a separate state. + * [Empty] (no contacts at all) or shows [Content]. How the address book was opened (browse vs. pick a recipient) only + * changes what a contact tap does, which is captured by [ContactUM.onClick] and [Content.contentMode]. */ @Immutable internal sealed interface AddressBookListUM { data class Empty(val onAddClick: () -> Unit) : AddressBookListUM + /** + * @property searchBar always shown so the user can filter contacts across all wallets. + * @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden. + * @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true. + * @property isNothingFound true when the active search matched nothing — show the "no results" stub instead of the + * list (the search bar stays visible so the query can be edited). + */ data class Content( + val searchBar: TangemSearch.State, + val chips: ImmutableList, val contacts: ImmutableList, + val isNothingFound: Boolean, val contentMode: ContentMode, ) : AddressBookListUM } 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 d15a700047..fcb37f7eb9 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 @@ -6,8 +6,10 @@ import com.tangem.common.ui.account.AccountIconUM @Immutable internal data class ContactUM( val id: String, + val walletId: String, val name: String, val icon: AccountIconUM.CryptoPortfolio, val networkAddressCount: Int, + val walletName: String? = null, val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt new file mode 100644 index 0000000000..a37e5b1bdf --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt @@ -0,0 +1,182 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.model.* +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.route.AddressBookRoute +import org.junit.jupiter.api.Test + +internal class UpdateAddressBookListContentTransformerTest { + + private val wallet1 = "00" + private val wallet2 = "01" + + private val wallets: Map = linkedMapOf( + UserWalletId(stringValue = wallet1) to wallet(wallet1, "Wallet 1"), + UserWalletId(stringValue = wallet2) to wallet(wallet2, "Wallet 2"), + ) + + @Test + fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips).isEmpty() + assertThat(content.contacts).hasSize(2) + assertThat(content.isNothingFound).isFalse() + // Single-wallet book has no chips, so the wallet name is not shown. + assertThat(content.contacts.none { it.walletName != null }).isTrue() + } + + @Test + fun `GIVEN contacts in two wallets WHEN blank query THEN All plus per-wallet chips with All selected`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder() + assertThat(content.chips.first().isSelected).isTrue() // All + assertThat(content.chips.drop(1).none { it.isSelected }).isTrue() + assertThat(content.contacts).hasSize(2) + // On the "All" chip of a multi-wallet book each contact shows its wallet name. + assertThat(content.contacts.map { it.walletName }).containsExactly("Wallet 1", "Wallet 2").inOrder() + } + + @Test + fun `GIVEN two wallets WHEN a wallet chip selected THEN list filtered but chips unchanged`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder() + assertThat(content.chips.first().isSelected).isFalse() // All not selected + assertThat(content.contacts.map { it.name }).containsExactly("Bob") + // A specific wallet is selected, so the (redundant) wallet name is not shown. + assertThat(content.contacts.single().walletName).isNull() + } + + @Test + fun `GIVEN two wallets WHEN query narrows to one wallet THEN chips kept as All plus that wallet`() { + // Arrange — the book spans two wallets, but the query matched only wallet1 in the domain + val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob")) + val matched = listOf(verified(wallet1, "Antonio")) + + // Act + val result = transform(allContacts = all, matchedContacts = matched, query = "Anto") + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1).inOrder() + assertThat(content.contacts.map { it.name }).containsExactly("Antonio") + assertThat(content.isNothingFound).isFalse() + } + + @Test + fun `GIVEN selected wallet no longer matches query THEN falls back to All`() { + // Arrange + val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob")) + val matched = listOf(verified(wallet1, "Antonio")) + + // Act — selected wallet2, but query matched only wallet1 + val result = transform( + allContacts = all, + matchedContacts = matched, + selectedWalletId = wallet2, + query = "Anto", + ) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.first().isSelected).isTrue() // All selected again + assertThat(content.contacts.map { it.name }).containsExactly("Antonio") + } + + @Test + fun `GIVEN no contacts WHEN blank query THEN Empty`() { + // Act + val result = transform(allContacts = emptyList(), matchedContacts = emptyList()) + + // Assert + assertThat(result).isInstanceOf(AddressBookListUM.Empty::class.java) + } + + @Test + fun `GIVEN query matches nothing WHEN non-blank query THEN nothing found and chips hidden`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz") + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.isNothingFound).isTrue() + assertThat(content.contacts).isEmpty() + assertThat(content.chips).isEmpty() + } + + private fun transform( + allContacts: List, + matchedContacts: List, + selectedWalletId: String? = null, + query: String = "", + ): AddressBookListUM = UpdateAddressBookListContentTransformer( + allContacts = allContacts, + matchedContacts = matchedContacts, + mode = AddressBookRoute.ListMode.Default, + wallets = wallets, + selectedWalletId = selectedWalletId, + query = query, + isSearchActive = false, + onContactClick = {}, + onPickContact = {}, + onQueryChange = {}, + onActiveChange = {}, + onClearQuery = {}, + onChipSelected = {}, + onAddContactClick = {}, + ).transform(prevState = AddressBookListUM.Empty(onAddClick = {})) + + private fun wallet(id: String, name: String): UserWallet = + MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name) + + private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact( + contact = Contact( + id = ContactId(name + walletId), + walletId = UserWalletId(stringValue = walletId), + name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, + icon = "", + iconColor = "Azure", + createdAt = "2026-06-10T14:30:00.000Z", + updatedAt = "2026-06-10T14:30:00.000Z", + addressEntries = listOf( + AddressEntry( + id = AddressEntryId(name), + address = "addr-$name", + networkId = Network.RawID("ethereum"), + memo = null, + signature = "sig", + networkName = "Ethereum", + ), + ), + ), + invalidEntries = emptyList(), + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index c6afc17f98..e2f98cccea 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -38,7 +38,6 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( contactsBlockFactory.create( context = child("send_contacts_block"), params = AddressBookContactsBlockComponent.Params( - userWalletId = params.userWalletId, network = params.cryptoCurrency.network, queryFlow = model.addressQuery, onContactClick = model::onContactClick,