Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 15:40:12 +02:00
parent e359847e72
commit 9898764f4d
8 changed files with 206 additions and 15 deletions

View file

@ -18,6 +18,7 @@ import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListQueryTransformer
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -68,15 +69,14 @@ internal class AddressBookListModel @Inject constructor(
allContacts,
matchedContacts,
searchQuery,
combine(selectedWalletId, searchActive) { selected, active -> selected to active },
selectedWalletId,
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { all, matched, query, (selected, active), wallets ->
) { all, matched, query, selected, wallets ->
ListInputs(
allContacts = all,
matchedContacts = matched,
query = query,
selectedWalletId = selected,
isSearchActive = active,
wallets = wallets,
)
}
@ -94,7 +94,6 @@ internal class AddressBookListModel @Inject constructor(
wallets = inputs.wallets,
selectedWalletId = inputs.selectedWalletId,
query = inputs.query,
isSearchActive = inputs.isSearchActive,
onContactClick = params.onContactClick,
onPickContact = ::onPickContact,
onQueryChange = ::onQueryChange,
@ -108,14 +107,21 @@ internal class AddressBookListModel @Inject constructor(
private fun onQueryChange(query: String) {
searchQuery.value = query
updateSearchBar(query = query, isActive = searchActive.value)
}
private fun onActiveChange(active: Boolean) {
searchActive.value = active
updateSearchBar(query = searchQuery.value, isActive = active)
}
private fun onClearQuery() {
searchQuery.value = ""
updateSearchBar(query = "", isActive = searchActive.value)
}
private fun updateSearchBar(query: String, isActive: Boolean) {
stateController.update(UpdateAddressBookListQueryTransformer(query = query, isActive = isActive))
}
private fun onChipSelected(walletId: String?) {
@ -143,7 +149,6 @@ internal class AddressBookListModel @Inject constructor(
val matchedContacts: List<VerifiedContact>,
val query: String,
val selectedWalletId: String?,
val isSearchActive: Boolean,
val wallets: Map<UserWalletId, UserWallet>,
)
}

View file

@ -28,7 +28,6 @@ internal class UpdateAddressBookListContentTransformer(
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,
@ -61,7 +60,7 @@ internal class UpdateAddressBookListContentTransformer(
.toImmutableList()
return AddressBookListUM.Content(
searchBar = buildSearchBar(),
searchBar = (prevState as? AddressBookListUM.Content)?.searchBar ?: buildSearchBar(),
chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(),
contacts = displayContacts,
isNothingFound = matchedItems.isEmpty(),
@ -93,7 +92,7 @@ internal class UpdateAddressBookListContentTransformer(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = onQueryChange,
isActive = isSearchActive,
isActive = false,
onActiveChange = onActiveChange,
onClearClick = onClearQuery,
onCloseClick = { onActiveChange(false) },

View file

@ -0,0 +1,17 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer
internal class UpdateAddressBookListQueryTransformer(
private val query: String,
private val isActive: Boolean,
) : Transformer<AddressBookListUM> {
override fun transform(prevState: AddressBookListUM): AddressBookListUM = when (prevState) {
is AddressBookListUM.Content -> prevState.copy(
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
)
is AddressBookListUM.Empty -> prevState
}
}

View file

@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -40,7 +41,9 @@ internal fun AddressBookListScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.navigationBarsPadding()) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(modifier = modifier) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),
@ -91,15 +94,12 @@ internal fun AddressBookListScreen(
modifier = Modifier
.imePadding()
.padding(top = 16.dp)
.padding(horizontal = 16.dp)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = 12.dp,
),
contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight),
) {
items(items = state.contacts, key = ContactUM::id) { contact ->
ContactRow(contact = contact)

View file

@ -0,0 +1,169 @@
package com.tangem.features.addressbook.list.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddressBookListModelTest {
private val router: Router = mockk(relaxed = true)
private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true)
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private var model: AddressBookListModel? = null
@BeforeEach
fun resetMocks() {
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase)
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
flowOf(linkedMapOf<UserWalletId, UserWallet>())
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
// Arrange
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
advanceUntilIdle()
// Assert
val state = model.state.value as AddressBookListUM.Content
assertThat(state.contentMode).isInstanceOf(ContentMode.Default::class.java)
assertThat(state.contacts.map { it.name }).containsExactly("Alice", "Bob")
}
@Test
fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest {
// Arrange
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns flowOf(emptyList())
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
advanceUntilIdle()
// Assert
assertThat(model.state.value).isInstanceOf(AddressBookListUM.Empty::class.java)
}
@Test
fun `GIVEN default mode WHEN contact clicked THEN editor opened with contact id`() = runTest {
// Arrange
var clickedId: String? = null
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
val model = createModel(
testScope = this,
mode = AddressBookRoute.ListMode.Default,
onContactClick = { clickedId = it },
)
advanceUntilIdle()
// Act
(model.state.value as AddressBookListUM.Content).contacts.first().onClick()
// Assert
assertThat(clickedId).isEqualTo("42")
}
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact(
contact = Contact(
id = ContactId(id),
walletId = UserWalletId("a"),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = CryptoPortfolioIcon.Color.Azure.name,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("e-$id"),
address = "0xABC",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "sig",
),
),
),
invalidEntries = emptyList(),
)
private fun createModel(
testScope: TestScope,
mode: AddressBookRoute.ListMode,
onContactClick: (String) -> Unit = {},
onAddContactClick: () -> Unit = {},
): AddressBookListModel {
val params = DefaultAddressBookListComponent.Params(
mode = mode,
onContactClick = onContactClick,
onAddContactClick = onAddContactClick,
)
return AddressBookListModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
stateController = AddressBookListStateController(),
router = router,
contactSelectionTrigger = contactSelectionTrigger,
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
getWalletsUseCase = getWalletsUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
const val TIMESTAMP = "2026-06-10T14:30:00.000Z"
}
}

View file

@ -144,7 +144,6 @@ internal class UpdateAddressBookListContentTransformerTest {
wallets = wallets,
selectedWalletId = selectedWalletId,
query = query,
isSearchActive = false,
onContactClick = {},
onPickContact = {},
onQueryChange = {},

View file

@ -63,6 +63,7 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
cryptoCurrency = params.cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty,
isAddContactAvailable = true,
),
onResult = {},
onClick = {},

View file

@ -81,6 +81,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
cryptoCurrency = model.secondaryCurrency,
predefinedValues = PredefinedValues.Empty,
isAllowSelfSend = true,
isAddContactAvailable = true,
),
// No feedback: the read-only block is driven one-way by the model.uiState collector ([REDACTED_TASK_KEY]).
onResult = {},