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 0f2cfb6a66..c000ba3ea3 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 @@ -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, 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 index c6eeaaf47d..d5d13cb810 100644 --- 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 @@ -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) }, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt new file mode 100644 index 0000000000..b78a450a7b --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt @@ -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 { + + 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 + } +} \ 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 e999fdeac3..9a17943008 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 @@ -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) diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt new file mode 100644 index 0000000000..454ab14316 --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -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()) + } + + @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" + } +} \ 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 index a37e5b1bdf..fefa1bde60 100644 --- 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 @@ -144,7 +144,6 @@ internal class UpdateAddressBookListContentTransformerTest { wallets = wallets, selectedWalletId = selectedWalletId, query = query, - isSearchActive = false, onContactClick = {}, onPickContact = {}, onQueryChange = {}, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt index 48dca29d62..2642e9da0d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt @@ -63,6 +63,7 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrencyStatus.currency, blockClickEnableFlow = MutableStateFlow(false), predefinedValues = PredefinedValues.Empty, + isAddContactAvailable = true, ), onResult = {}, onClick = {}, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 5b4ab5611d..9ae5d97b6f 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -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 = {},