Updated on 2026-08-14
This commit is contained in:
parent
e359847e72
commit
9898764f4d
8 changed files with 206 additions and 15 deletions
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +144,6 @@ internal class UpdateAddressBookListContentTransformerTest {
|
|||
wallets = wallets,
|
||||
selectedWalletId = selectedWalletId,
|
||||
query = query,
|
||||
isSearchActive = false,
|
||||
onContactClick = {},
|
||||
onPickContact = {},
|
||||
onQueryChange = {},
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
|
|||
cryptoCurrency = params.cryptoCurrencyStatus.currency,
|
||||
blockClickEnableFlow = MutableStateFlow(false),
|
||||
predefinedValues = PredefinedValues.Empty,
|
||||
isAddContactAvailable = true,
|
||||
),
|
||||
onResult = {},
|
||||
onClick = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue