Updated on 2026-08-14
This commit is contained in:
parent
277662b1ba
commit
0c4d14650d
25 changed files with 829 additions and 213 deletions
|
|
@ -3,7 +3,6 @@ package com.tangem.features.addressbook
|
|||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +15,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent {
|
|||
interface Factory : ComponentFactory<Params, AddressBookContactsBlockComponent>
|
||||
|
||||
/**
|
||||
* @property userWalletId the sending wallet whose address book is shown
|
||||
* @property network the current send network; only contacts with an address in this network are shown
|
||||
* @property queryFlow the live recipient-input text used to filter the block
|
||||
* @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides
|
||||
|
|
@ -24,7 +22,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent {
|
|||
* @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode
|
||||
*/
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val network: Network,
|
||||
val queryFlow: StateFlow<String>,
|
||||
val onContactClick: (MatchedContact) -> Unit,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ private fun Preview_AddressSelectorList() {
|
|||
AddressSelectorList(
|
||||
contact = MatchedContact(
|
||||
contactId = "1",
|
||||
walletId = "00",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped
|
|||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
|
||||
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
||||
|
|
@ -11,11 +12,7 @@ import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
|||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
|
@ -25,6 +22,7 @@ internal class ContactsBlockModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||
|
|
@ -32,13 +30,19 @@ internal class ContactsBlockModel @Inject constructor(
|
|||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
params.queryFlow
|
||||
.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) }
|
||||
.onEach { contacts ->
|
||||
combine(
|
||||
params.queryFlow.flatMapLatest { query ->
|
||||
getContactsUseCase(query = query, userWalletId = null)
|
||||
},
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { contacts, wallets -> contacts to wallets.values.toList() }
|
||||
.onEach { (contacts, wallets) ->
|
||||
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
||||
stateController.update(
|
||||
UpdateContactsBlockStateTransformer(
|
||||
matched = matched,
|
||||
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
|
||||
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
|
||||
onSeeAllClick = params.onSeeAllClick,
|
||||
onContactClick = params.onContactClick,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */
|
||||
internal class UpdateContactsBlockStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val walletNamesById: Map<String, String>,
|
||||
private val shouldShowWalletName: Boolean,
|
||||
private val onSeeAllClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<ContactsBlockUM> {
|
||||
|
|
@ -29,9 +31,11 @@ internal class UpdateContactsBlockStateTransformer(
|
|||
|
||||
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
walletId = walletId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
walletName = if (shouldShowWalletName) walletNamesById[walletId] else null,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
*/
|
||||
internal object ContactMatcher {
|
||||
|
||||
private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure
|
||||
|
||||
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
|
||||
return contacts.mapNotNull { contact ->
|
||||
val entries = contact.addressEntries.filter { it.networkId.value == networkId }
|
||||
|
|
@ -21,11 +19,9 @@ internal object ContactMatcher {
|
|||
|
||||
MatchedContact(
|
||||
contactId = contact.id.value,
|
||||
walletId = contact.walletId.stringValue,
|
||||
name = contact.name.value,
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = contact.resolveIconColor(),
|
||||
),
|
||||
icon = contact.toAvatarIcon(),
|
||||
networkId = networkId,
|
||||
entries = entries.map { entry ->
|
||||
MatchedContact.ContactAddress(
|
||||
|
|
@ -38,6 +34,10 @@ internal object ContactMatcher {
|
|||
}
|
||||
}
|
||||
|
||||
private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color =
|
||||
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR
|
||||
/** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */
|
||||
private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
|
||||
?: CryptoPortfolioIcon.Color.Azure,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -7,31 +7,33 @@ import com.tangem.core.decompose.di.ModelScoped
|
|||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the contacts list. The list content is the same however the address book was opened — the open
|
||||
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]).
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]).
|
||||
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is
|
||||
* returned right away, several open the address selector first.
|
||||
*/
|
||||
@Suppress("LongParameterList", "NamedArguments")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
internal class AddressBookListModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
|
|
@ -39,7 +41,8 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val stateController: AddressBookListStateController,
|
||||
private val router: Router,
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val getContactsUseCase: GetContactsUseCase,
|
||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
|
||||
|
|
@ -49,32 +52,77 @@ internal class AddressBookListModel @Inject constructor(
|
|||
/** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
|
||||
val selectorNavigation = SlotNavigation<MatchedContact>()
|
||||
|
||||
init {
|
||||
when (val mode = params.mode) {
|
||||
// Browse/manage: full list UI is TODO [REDACTED_TASK_KEY].
|
||||
AddressBookRoute.ListMode.Default -> stateController.update(
|
||||
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
|
||||
)
|
||||
// Pick a recipient: same list, the tap returns the chosen address.
|
||||
is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId)
|
||||
}
|
||||
}
|
||||
private val searchQuery = MutableStateFlow(value = "")
|
||||
private val searchActive = MutableStateFlow(value = false)
|
||||
private val selectedWalletId = MutableStateFlow<String?>(value = null)
|
||||
|
||||
private fun observeSelectionContacts(networkId: String) {
|
||||
getContactsUseCase(query = "")
|
||||
.onEach { contacts ->
|
||||
stateController.update(
|
||||
UpdateAddressBookListSelectionStateTransformer(
|
||||
matched = ContactMatcher.match(contacts = contacts, networkId = networkId),
|
||||
onAddContactClick = params.onAddContactClick,
|
||||
onContactClick = ::onPickContact,
|
||||
),
|
||||
)
|
||||
}
|
||||
private val allContacts: SharedFlow<List<VerifiedContact>> =
|
||||
getVerifiedContactsInteractor(query = "", userWalletId = null)
|
||||
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
|
||||
|
||||
init {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null)
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
combine(selectedWalletId, searchActive) { selected, active -> selected to active },
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, (selected, active), wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
isSearchActive = active,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun updateState(inputs: ListInputs) {
|
||||
stateController.update(
|
||||
UpdateAddressBookListContentTransformer(
|
||||
allContacts = inputs.allContacts,
|
||||
matchedContacts = inputs.matchedContacts,
|
||||
mode = params.mode,
|
||||
wallets = inputs.wallets,
|
||||
selectedWalletId = inputs.selectedWalletId,
|
||||
query = inputs.query,
|
||||
isSearchActive = inputs.isSearchActive,
|
||||
onContactClick = params.onContactClick,
|
||||
onPickContact = ::onPickContact,
|
||||
onQueryChange = ::onQueryChange,
|
||||
onActiveChange = ::onActiveChange,
|
||||
onClearQuery = ::onClearQuery,
|
||||
onChipSelected = ::onChipSelected,
|
||||
onAddContactClick = params.onAddContactClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onQueryChange(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
private fun onActiveChange(active: Boolean) {
|
||||
searchActive.value = active
|
||||
}
|
||||
|
||||
private fun onClearQuery() {
|
||||
searchQuery.value = ""
|
||||
}
|
||||
|
||||
private fun onChipSelected(walletId: String?) {
|
||||
if (selectedWalletId.value == walletId) return
|
||||
selectedWalletId.value = walletId
|
||||
}
|
||||
|
||||
private fun onPickContact(contact: MatchedContact) {
|
||||
val singleEntry = contact.entries.singleOrNull()
|
||||
if (singleEntry != null) {
|
||||
|
|
@ -89,4 +137,13 @@ internal class AddressBookListModel @Inject constructor(
|
|||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
}
|
||||
|
||||
private data class ListInputs(
|
||||
val allContacts: List<VerifiedContact>,
|
||||
val matchedContacts: List<VerifiedContact>,
|
||||
val query: String,
|
||||
val selectedWalletId: String?,
|
||||
val isSearchActive: Boolean,
|
||||
val wallets: Map<UserWalletId, UserWallet>,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.search.TangemSearch
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter
|
||||
import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class UpdateAddressBookListContentTransformer(
|
||||
wallets: Map<UserWalletId, UserWallet>,
|
||||
private val allContacts: List<VerifiedContact>,
|
||||
private val matchedContacts: List<VerifiedContact>,
|
||||
private val mode: AddressBookRoute.ListMode,
|
||||
private val selectedWalletId: String?,
|
||||
private val query: String,
|
||||
private val isSearchActive: Boolean,
|
||||
private val onContactClick: (String) -> Unit,
|
||||
private val onPickContact: (MatchedContact) -> Unit,
|
||||
private val onQueryChange: (String) -> Unit,
|
||||
private val onActiveChange: (Boolean) -> Unit,
|
||||
private val onClearQuery: () -> Unit,
|
||||
private val onChipSelected: (String?) -> Unit,
|
||||
private val onAddContactClick: () -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
private val orderedWalletIds: List<String> = wallets.values.map { it.walletId.stringValue }
|
||||
private val walletNamesById: Map<String, String> = wallets.values.associate { it.walletId.stringValue to it.name }
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
val matchedItems = matchedItems()
|
||||
|
||||
if (matchedItems.isEmpty() && query.isBlank()) {
|
||||
return AddressBookListUM.Empty(onAddClick = onAddContactClick)
|
||||
}
|
||||
|
||||
val matchingWalletIds = matchedItems.map { it.walletId }.distinct()
|
||||
val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds }
|
||||
val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty()
|
||||
|
||||
// On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to.
|
||||
val shouldShowWalletName = areChipsVisible && effectiveSelected == null
|
||||
|
||||
val displayContacts = matchedItems
|
||||
.filter { effectiveSelected == null || it.walletId == effectiveSelected }
|
||||
.map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it }
|
||||
.toImmutableList()
|
||||
|
||||
return AddressBookListUM.Content(
|
||||
searchBar = buildSearchBar(),
|
||||
chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(),
|
||||
contacts = displayContacts,
|
||||
isNothingFound = matchedItems.isEmpty(),
|
||||
contentMode = contentMode(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun matchedItems(): List<ContactUM> = when (val mode = mode) {
|
||||
AddressBookRoute.ListMode.Default ->
|
||||
DefaultContactConverter(onContactClick).convertList(matchedContacts)
|
||||
is AddressBookRoute.ListMode.Selector ->
|
||||
SelectorContactConverter(onPickContact)
|
||||
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId))
|
||||
}
|
||||
|
||||
/** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */
|
||||
private fun totalWalletIds(): Set<String> = when (val mode = mode) {
|
||||
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue }
|
||||
is AddressBookRoute.ListMode.Selector ->
|
||||
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
|
||||
}
|
||||
|
||||
private fun contentMode(): ContentMode = when (mode) {
|
||||
AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick)
|
||||
is AddressBookRoute.ListMode.Selector -> ContentMode.Select
|
||||
}
|
||||
|
||||
private fun buildSearchBar(): TangemSearch.State = TangemSearch.State(
|
||||
placeholderText = resourceReference(R.string.common_search),
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
isActive = isSearchActive,
|
||||
onActiveChange = onActiveChange,
|
||||
onClearClick = onClearQuery,
|
||||
onCloseClick = { onActiveChange(false) },
|
||||
)
|
||||
|
||||
private fun buildChips(matchingWalletIds: List<String>, effectiveSelected: String?) = buildList {
|
||||
add(
|
||||
AddressBookChipUM(
|
||||
id = ALL_CHIP_ID,
|
||||
text = resourceReference(R.string.common_all),
|
||||
isSelected = effectiveSelected == null,
|
||||
onClick = { onChipSelected(null) },
|
||||
),
|
||||
)
|
||||
orderedWalletIds
|
||||
.filter { it in matchingWalletIds }
|
||||
.forEach { walletId ->
|
||||
add(
|
||||
AddressBookChipUM(
|
||||
id = walletId,
|
||||
text = stringReference(walletNamesById[walletId] ?: walletId),
|
||||
isSelected = walletId == effectiveSelected,
|
||||
onClick = { onChipSelected(walletId) },
|
||||
iconRes = R.drawable.ic_key_card_20,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
|
||||
private companion object {
|
||||
const val ALL_CHIP_ID = "all"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the "add contact" callback owned by the container into the initial (empty) list state.
|
||||
*/
|
||||
internal class UpdateAddressBookListInitialStateTransformer(
|
||||
private val onAddContactClick: () -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return when (prevState) {
|
||||
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
|
||||
is AddressBookListUM.Content -> prevState.copy(
|
||||
contentMode = ContentMode.Default(onAddClick = onAddContactClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the
|
||||
* user can still add a contact.
|
||||
*/
|
||||
internal class UpdateAddressBookListSelectionStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val onAddContactClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return if (matched.isEmpty()) {
|
||||
AddressBookListUM.Empty(onAddClick = onAddContactClick)
|
||||
} else {
|
||||
AddressBookListUM.Content(
|
||||
contacts = matched.map { it.toContactUM() }.toImmutableList(),
|
||||
contentMode = ContentMode.Select,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatchedContact.toContactUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers.converter
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class DefaultContactConverter(
|
||||
private val onContactClick: (String) -> Unit,
|
||||
) : Converter<VerifiedContact, ContactUM> {
|
||||
|
||||
override fun convert(value: VerifiedContact): ContactUM {
|
||||
val contact = value.contact
|
||||
val name = contact.name.value
|
||||
return ContactUM(
|
||||
id = contact.id.value,
|
||||
walletId = contact.walletId.stringValue,
|
||||
name = name,
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon }
|
||||
?: CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
|
||||
?: CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = contact.addressEntries.size,
|
||||
onClick = { onContactClick(contact.id.value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers.converter
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class SelectorContactConverter(
|
||||
private val onPickContact: (MatchedContact) -> Unit,
|
||||
) : Converter<MatchedContact, ContactUM> {
|
||||
|
||||
override fun convert(value: MatchedContact): ContactUM = ContactUM(
|
||||
id = value.contactId,
|
||||
walletId = value.walletId,
|
||||
name = value.name,
|
||||
icon = value.icon,
|
||||
networkAddressCount = value.entries.size,
|
||||
onClick = { onPickContact(value) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,38 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.search.TangemSearch
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.core.ui.res.generated.icons.*
|
||||
import com.tangem.features.addressbook.common.ui.ContactRow
|
||||
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider
|
||||
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookListScreen(
|
||||
|
|
@ -34,7 +40,7 @@ internal fun AddressBookListScreen(
|
|||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.navigationBarsPadding()) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
|
|
@ -68,76 +74,93 @@ internal fun AddressBookListScreen(
|
|||
)
|
||||
},
|
||||
)
|
||||
LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
items(items = state.contacts, key = ContactUM::id) { contact ->
|
||||
ContactRow(contact = contact)
|
||||
|
||||
TangemSearch(
|
||||
state = state.searchBar,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
|
||||
if (state.chips.isNotEmpty()) {
|
||||
WalletChips(chips = state.chips)
|
||||
}
|
||||
|
||||
if (state.isNothingFound) {
|
||||
NothingFoundContent()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.padding(top = 16.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
) {
|
||||
items(items = state.contacts, key = ContactUM::id) { contact ->
|
||||
ContactRow(contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressBookListScreen() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Default(onAddClick = {}),
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Select,
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
private fun WalletChips(chips: ImmutableList<AddressBookChipUM>) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = chips, key = AddressBookChipUM::id) { chip ->
|
||||
AddressBookChip(state = chip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.NothingFoundContent() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.ic_search_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.common_no_results),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
||||
private fun Preview_AddressBookListScreen(
|
||||
@PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookListScreen(
|
||||
state = scenario.state,
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.tangem.features.addressbook.list.ui.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.search.TangemSearch
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal data class AddressBookListPreviewScenario(
|
||||
val title: String,
|
||||
val state: AddressBookListUM.Content,
|
||||
)
|
||||
|
||||
internal object AddressBookListPreviewFixtures {
|
||||
|
||||
private val scenarioDefaultWithContacts = AddressBookListPreviewScenario(
|
||||
title = "Default – chips and contacts",
|
||||
state = AddressBookListUM.Content(
|
||||
searchBar = searchBar(query = ""),
|
||||
chips = persistentListOf(
|
||||
AddressBookChipUM(
|
||||
id = "all",
|
||||
text = resourceReference(R.string.common_all),
|
||||
isSelected = true,
|
||||
onClick = {},
|
||||
),
|
||||
AddressBookChipUM(
|
||||
id = "00",
|
||||
text = stringReference("Wallet 1"),
|
||||
isSelected = false,
|
||||
onClick = {},
|
||||
iconRes = R.drawable.ic_key_card_20,
|
||||
),
|
||||
AddressBookChipUM(
|
||||
id = "01",
|
||||
text = stringReference("Wallet 2"),
|
||||
isSelected = false,
|
||||
onClick = {},
|
||||
iconRes = R.drawable.ic_key_card_20,
|
||||
),
|
||||
),
|
||||
contacts = persistentListOf(
|
||||
contact(
|
||||
walletId = "00",
|
||||
name = "Binance",
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
count = 1,
|
||||
),
|
||||
contact(
|
||||
walletId = "01",
|
||||
name = "Alice",
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
count = 3,
|
||||
),
|
||||
),
|
||||
isNothingFound = false,
|
||||
contentMode = ContentMode.Default(onAddClick = {}),
|
||||
),
|
||||
)
|
||||
|
||||
val scenarioSelectNothingFound = AddressBookListPreviewScenario(
|
||||
title = "Select – nothing found",
|
||||
state = AddressBookListUM.Content(
|
||||
searchBar = searchBar(query = "Antonio"),
|
||||
chips = persistentListOf(),
|
||||
contacts = persistentListOf(),
|
||||
isNothingFound = true,
|
||||
contentMode = ContentMode.Select,
|
||||
),
|
||||
)
|
||||
|
||||
fun allScenarios(): List<AddressBookListPreviewScenario> = listOf(
|
||||
scenarioDefaultWithContacts,
|
||||
scenarioSelectNothingFound,
|
||||
)
|
||||
|
||||
private fun searchBar(query: String) = TangemSearch.State(
|
||||
placeholderText = resourceReference(R.string.common_search),
|
||||
query = query,
|
||||
onQueryChange = {},
|
||||
isActive = false,
|
||||
onActiveChange = {},
|
||||
)
|
||||
|
||||
private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM(
|
||||
id = name + walletId,
|
||||
walletId = walletId,
|
||||
name = name,
|
||||
icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color),
|
||||
networkAddressCount = count,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */
|
||||
internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider<AddressBookListPreviewScenario> {
|
||||
override val values: Sequence<AddressBookListPreviewScenario>
|
||||
get() = AddressBookListPreviewFixtures.allScenarios().asSequence()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -1,20 +1,31 @@
|
|||
package com.tangem.features.addressbook.list.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds2.search.TangemSearch
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* UI state of the contacts list. The list itself is the same however the address book was opened — it is either
|
||||
* [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a
|
||||
* contact tap does, which is captured by [ContactUM.onClick], not by a separate state.
|
||||
* [Empty] (no contacts at all) or shows [Content]. How the address book was opened (browse vs. pick a recipient) only
|
||||
* changes what a contact tap does, which is captured by [ContactUM.onClick] and [Content.contentMode].
|
||||
*/
|
||||
@Immutable
|
||||
internal sealed interface AddressBookListUM {
|
||||
|
||||
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
|
||||
|
||||
/**
|
||||
* @property searchBar always shown so the user can filter contacts across all wallets.
|
||||
* @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden.
|
||||
* @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true.
|
||||
* @property isNothingFound true when the active search matched nothing — show the "no results" stub instead of the
|
||||
* list (the search bar stays visible so the query can be edited).
|
||||
*/
|
||||
data class Content(
|
||||
val searchBar: TangemSearch.State,
|
||||
val chips: ImmutableList<AddressBookChipUM>,
|
||||
val contacts: ImmutableList<ContactUM>,
|
||||
val isNothingFound: Boolean,
|
||||
val contentMode: ContentMode,
|
||||
) : AddressBookListUM
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class UpdateAddressBookListContentTransformerTest {
|
||||
|
||||
private val wallet1 = "00"
|
||||
private val wallet2 = "01"
|
||||
|
||||
private val wallets: Map<UserWalletId, UserWallet> = linkedMapOf(
|
||||
UserWalletId(stringValue = wallet1) to wallet(wallet1, "Wallet 1"),
|
||||
UserWalletId(stringValue = wallet2) to wallet(wallet2, "Wallet 2"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() {
|
||||
// Arrange
|
||||
val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob"))
|
||||
|
||||
// Act
|
||||
val result = transform(allContacts = all, matchedContacts = all)
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.chips).isEmpty()
|
||||
assertThat(content.contacts).hasSize(2)
|
||||
assertThat(content.isNothingFound).isFalse()
|
||||
// Single-wallet book has no chips, so the wallet name is not shown.
|
||||
assertThat(content.contacts.none { it.walletName != null }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contacts in two wallets WHEN blank query THEN All plus per-wallet chips with All selected`() {
|
||||
// Arrange
|
||||
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
|
||||
|
||||
// Act
|
||||
val result = transform(allContacts = all, matchedContacts = all)
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
|
||||
assertThat(content.chips.first().isSelected).isTrue() // All
|
||||
assertThat(content.chips.drop(1).none { it.isSelected }).isTrue()
|
||||
assertThat(content.contacts).hasSize(2)
|
||||
// On the "All" chip of a multi-wallet book each contact shows its wallet name.
|
||||
assertThat(content.contacts.map { it.walletName }).containsExactly("Wallet 1", "Wallet 2").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two wallets WHEN a wallet chip selected THEN list filtered but chips unchanged`() {
|
||||
// Arrange
|
||||
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
|
||||
|
||||
// Act
|
||||
val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2)
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
|
||||
assertThat(content.chips.first().isSelected).isFalse() // All not selected
|
||||
assertThat(content.contacts.map { it.name }).containsExactly("Bob")
|
||||
// A specific wallet is selected, so the (redundant) wallet name is not shown.
|
||||
assertThat(content.contacts.single().walletName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two wallets WHEN query narrows to one wallet THEN chips kept as All plus that wallet`() {
|
||||
// Arrange — the book spans two wallets, but the query matched only wallet1 in the domain
|
||||
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
|
||||
val matched = listOf(verified(wallet1, "Antonio"))
|
||||
|
||||
// Act
|
||||
val result = transform(allContacts = all, matchedContacts = matched, query = "Anto")
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1).inOrder()
|
||||
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
|
||||
assertThat(content.isNothingFound).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN selected wallet no longer matches query THEN falls back to All`() {
|
||||
// Arrange
|
||||
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
|
||||
val matched = listOf(verified(wallet1, "Antonio"))
|
||||
|
||||
// Act — selected wallet2, but query matched only wallet1
|
||||
val result = transform(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
selectedWalletId = wallet2,
|
||||
query = "Anto",
|
||||
)
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.chips.first().isSelected).isTrue() // All selected again
|
||||
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no contacts WHEN blank query THEN Empty`() {
|
||||
// Act
|
||||
val result = transform(allContacts = emptyList(), matchedContacts = emptyList())
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(AddressBookListUM.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches nothing WHEN non-blank query THEN nothing found and chips hidden`() {
|
||||
// Arrange
|
||||
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
|
||||
|
||||
// Act
|
||||
val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz")
|
||||
|
||||
// Assert
|
||||
val content = result as AddressBookListUM.Content
|
||||
assertThat(content.isNothingFound).isTrue()
|
||||
assertThat(content.contacts).isEmpty()
|
||||
assertThat(content.chips).isEmpty()
|
||||
}
|
||||
|
||||
private fun transform(
|
||||
allContacts: List<VerifiedContact>,
|
||||
matchedContacts: List<VerifiedContact>,
|
||||
selectedWalletId: String? = null,
|
||||
query: String = "",
|
||||
): AddressBookListUM = UpdateAddressBookListContentTransformer(
|
||||
allContacts = allContacts,
|
||||
matchedContacts = matchedContacts,
|
||||
mode = AddressBookRoute.ListMode.Default,
|
||||
wallets = wallets,
|
||||
selectedWalletId = selectedWalletId,
|
||||
query = query,
|
||||
isSearchActive = false,
|
||||
onContactClick = {},
|
||||
onPickContact = {},
|
||||
onQueryChange = {},
|
||||
onActiveChange = {},
|
||||
onClearQuery = {},
|
||||
onChipSelected = {},
|
||||
onAddContactClick = {},
|
||||
).transform(prevState = AddressBookListUM.Empty(onAddClick = {}))
|
||||
|
||||
private fun wallet(id: String, name: String): UserWallet =
|
||||
MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name)
|
||||
|
||||
private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact(
|
||||
contact = Contact(
|
||||
id = ContactId(name + walletId),
|
||||
walletId = UserWalletId(stringValue = walletId),
|
||||
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
|
||||
icon = "",
|
||||
iconColor = "Azure",
|
||||
createdAt = "2026-06-10T14:30:00.000Z",
|
||||
updatedAt = "2026-06-10T14:30:00.000Z",
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId(name),
|
||||
address = "addr-$name",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
),
|
||||
invalidEntries = emptyList(),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue