diff --git a/app/jacoco-rules/unit/measured-composable-sources.txt b/app/jacoco-rules/unit/measured-composable-sources.txt index 87f0b6808..f3994b5d2 100644 --- a/app/jacoco-rules/unit/measured-composable-sources.txt +++ b/app/jacoco-rules/unit/measured-composable-sources.txt @@ -2,6 +2,7 @@ common/components/attachment/MediaThumbnail.kt common/components/mediapreview/MediaPreviewBackground.kt common/components/PrimaryActionButton.kt +common/components/SearchTopAppBarField.kt conversation/addparticipants/AddParticipantsScreen.kt conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt conversation/composer/ui/ConversationAttachmentPreview.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcherImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcherImplTest.kt new file mode 100644 index 000000000..771211cb3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcherImplTest.kt @@ -0,0 +1,197 @@ +package com.android.messaging.data.conversationlist.matcher + +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.SearchableConversation +import com.android.messaging.data.conversationlist.model.SearchableParticipant +import com.android.messaging.ui.conversationlist.conversationItem +import kotlinx.collections.immutable.toImmutableList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationSearchMatcherImplTest { + + private val matcher = ConversationSearchMatcherImpl() + + @Test + fun match_titleContainsQuery_findsConversationAndHighlightsTitle() { + val conversation = searchableConversation(id = "1", title = "Anna Smith") + + val matches = matcher.index(listOf(conversation)).match("smi") + + assertEquals(listOf(ConversationId("1")), matches.map { it.item.conversationId }) + assertEquals(listOf(5..7), matches.single().titleMatchRanges) + } + + @Test + fun match_differentCaseAndAccents_findsConversation() { + val conversation = searchableConversation(id = "1", title = "Zoë Ángel") + + val matches = matcher.index(listOf(conversation)).match("ZOE angel") + + assertEquals(1, matches.size) + assertEquals(listOf(0..2, 4..8), matches.single().titleMatchRanges) + } + + @Test + fun match_cyrillicInDifferentCase_findsConversation() { + val conversation = searchableConversation(id = "1", title = "Олена Петренко") + + val matches = matcher.index(listOf(conversation)).match("ПЕТР") + + assertEquals(listOf(6..9), matches.single().titleMatchRanges) + } + + @Test + fun match_titleWithDecomposedAccent_highlightsTheCombiningMarkToo() { + val conversation = searchableConversation(id = "1", title = "José Luis") + + val matches = matcher.index(listOf(conversation)).match("jose") + + assertEquals(listOf(0..4), matches.single().titleMatchRanges) + } + + @Test + fun match_participantFullNameOutsideGroupTitle_findsGroupWithoutHighlight() { + val conversation = searchableConversation( + id = "1", + title = "Anna, Bob", + participants = listOf( + participant(fullName = "Anna Smith", firstName = "Anna"), + participant(fullName = "Bob Jones", firstName = "Bob"), + ), + ) + + val matches = matcher.index(listOf(conversation)).match("jones") + + assertEquals(1, matches.size) + assertTrue(matches.single().titleMatchRanges.isEmpty()) + } + + @Test + fun match_wordsMatchingDifferentParticipants_findsTheirGroup() { + val group = searchableConversation( + id = "group", + title = "Anna, Bob", + participants = listOf( + participant(fullName = "Anna Smith"), + participant(fullName = "Bob Jones"), + ), + ) + val other = searchableConversation( + id = "other", + title = "Anna Smith", + participants = listOf(participant(fullName = "Anna Smith")), + ) + + val matches = matcher.index(listOf(group, other)).match("smith jones") + + assertEquals(listOf(ConversationId("group")), matches.map { it.item.conversationId }) + } + + @Test + fun match_phoneDigitsInAnyFormat_findsConversationByDestination() { + val conversation = searchableConversation( + id = "1", + title = "Mum", + participants = listOf(participant(normalizedDestination = "+15551234567")), + ) + + val matches = matcher.index(listOf(conversation)).match("(555) 123-45") + + assertEquals(1, matches.size) + } + + @Test + fun match_tooFewDigits_doesNotMatchDestination() { + val conversation = searchableConversation( + id = "1", + title = "Mum", + participants = listOf(participant(normalizedDestination = "+15551234567")), + ) + + val matches = matcher.index(listOf(conversation)).match("55") + + assertTrue(matches.isEmpty()) + } + + @Test + fun match_digitsMixedWithLetters_doesNotMatchDestination() { + val conversation = searchableConversation( + id = "1", + title = "Mum", + participants = listOf(participant(normalizedDestination = "+15551234567")), + ) + + val matches = matcher.index(listOf(conversation)).match("555 mum2") + + assertTrue(matches.isEmpty()) + } + + @Test + fun match_repeatedAndOverlappingTerms_mergesHighlightRanges() { + val conversation = searchableConversation(id = "1", title = "ana banana") + + val matches = matcher.index(listOf(conversation)).match("an nan") + + assertEquals(listOf(0..1, 5..8), matches.single().titleMatchRanges) + } + + @Test + fun match_blankQuery_findsNothing() { + val conversation = searchableConversation(id = "1", title = "Anna") + + val matches = matcher.index(listOf(conversation)).match(" ") + + assertTrue(matches.isEmpty()) + } + + @Test + fun match_noConversationMatches_findsNothing() { + val conversation = searchableConversation(id = "1", title = "Anna") + + val matches = matcher.index(listOf(conversation)).match("bob") + + assertTrue(matches.isEmpty()) + } + + @Test + fun match_severalMatches_keepsTheConversationsInTheirOrder() { + val conversations = listOf( + searchableConversation(id = "b", title = "Anna B"), + searchableConversation(id = "a", title = "Anna A"), + ) + + val matches = matcher.index(conversations).match("anna") + + assertEquals( + listOf(ConversationId("b"), ConversationId("a")), + matches.map { it.item.conversationId }, + ) + } + + private fun searchableConversation( + id: String, + title: String, + participants: List = emptyList(), + ): SearchableConversation { + return SearchableConversation( + item = conversationItem(conversationId = ConversationId(id)).copy(title = title), + participants = participants.toImmutableList(), + ) + } + + private fun participant( + fullName: String? = null, + firstName: String? = null, + displayDestination: String? = null, + normalizedDestination: String? = null, + ): SearchableParticipant { + return SearchableParticipant( + fullName = fullName, + firstName = firstName, + displayDestination = displayDestination, + normalizedDestination = normalizedDestination, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepositoryImplTest.kt new file mode 100644 index 000000000..3629e0f33 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepositoryImplTest.kt @@ -0,0 +1,199 @@ +package com.android.messaging.data.conversationlist.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListMode +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.model.SearchableConversation +import com.android.messaging.data.conversationlist.model.SearchableParticipant +import com.android.messaging.data.conversationlist.store.ConversationListSearchDatabaseStore +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversationlist.conversationItem +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationListSearchRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val contentResolver = mockk() + private val conversationListRepository = mockk() + private val databaseStore = mockk() + private val registeredObserver = slot() + + @Before + fun setUp() { + every { + contentResolver.registerContentObserver(any(), true, capture(registeredObserver)) + } just runs + every { contentResolver.unregisterContentObserver(any()) } just runs + every { databaseStore.readParticipants() } returns persistentMapOf() + } + + @Test + fun observeSearchableConversations_listsInboxThenArchivedWithTheirParticipants() { + runTest(context = mainDispatcherRule.testDispatcher) { + val inboxItem = conversationItem(conversationId = INBOX_ID) + val archivedItem = conversationItem(conversationId = ARCHIVED_ID, isArchived = true) + stubSnapshots( + inbox = snapshot(inboxItem), + archived = snapshot(archivedItem), + ) + every { databaseStore.readParticipants() } returns persistentMapOf( + INBOX_ID to persistentListOf(ANNA), + ) + + createRepository().observeSearchableConversations().test { + assertEquals( + listOf( + SearchableConversation( + item = inboxItem, + participants = persistentListOf(ANNA), + ), + SearchableConversation( + item = archivedItem, + participants = persistentListOf(), + ), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeSearchableConversations_leavesOutDirectChatsWithBlockedSenders() { + runTest(context = mainDispatcherRule.testDispatcher) { + val blockedItem = conversationItem(conversationId = BLOCKED_ID, isArchived = true) + val blockedGroup = conversationItem(conversationId = GROUP_ID).let { item -> + item.copy(participant = item.participant.copy(isGroup = true)) + } + val blockedDestination = blockedItem.participant.otherNormalizedDestination.orEmpty() + stubSnapshots( + inbox = snapshot( + blockedGroup.copy( + participant = blockedGroup.participant.copy( + otherNormalizedDestination = blockedDestination, + ), + ), + blockedDestinations = setOf(blockedDestination), + ), + archived = snapshot(blockedItem), + ) + + createRepository().observeSearchableConversations().test { + assertEquals( + listOf(GROUP_ID), + awaitItem().map { conversation -> conversation.item.conversationId }, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeSearchableConversations_conversationsOrParticipantsChange_readsParticipantsAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + stubSnapshots( + inbox = snapshot(conversationItem(conversationId = INBOX_ID)), + archived = snapshot(), + ) + + createRepository().observeSearchableConversations().test { + awaitItem() + + every { databaseStore.readParticipants() } returns persistentMapOf( + INBOX_ID to persistentListOf(ANNA), + ) + registeredObserver.captured.onChange(false) + + assertEquals(persistentListOf(ANNA), awaitItem().single().participants) + cancelAndIgnoreRemainingEvents() + } + + verify { + contentResolver.registerContentObserver( + MessagingContentProvider.CONVERSATIONS_URI, + true, + any(), + ) + } + verify { + contentResolver.registerContentObserver( + MessagingContentProvider.PARTICIPANTS_URI, + true, + any(), + ) + } + } + } + + private fun stubSnapshots( + inbox: ConversationListSnapshot, + archived: ConversationListSnapshot, + ) { + every { + conversationListRepository.observeSnapshot(mode = ConversationListMode.Inbox) + } returns flowOf(inbox) + every { + conversationListRepository.observeSnapshot(mode = ConversationListMode.Archived) + } returns flowOf(archived) + } + + private fun snapshot( + vararg items: ConversationListItem, + blockedDestinations: Set = emptySet(), + ): ConversationListSnapshot { + return ConversationListSnapshot( + items = items.toList().toImmutableList(), + blockedDestinations = persistentSetOf().addAll(blockedDestinations), + hasFirstSyncCompleted = true, + ) + } + + private fun createRepository(): ConversationListSearchRepositoryImpl { + return ConversationListSearchRepositoryImpl( + contentResolver = contentResolver, + conversationListRepository = conversationListRepository, + databaseStore = databaseStore, + messagingDbDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private companion object { + private val INBOX_ID = ConversationId("1") + private val ARCHIVED_ID = ConversationId("2") + private val BLOCKED_ID = ConversationId("3") + private val GROUP_ID = ConversationId("4") + + private val ANNA = SearchableParticipant( + fullName = "Anna Smith", + firstName = "Anna", + displayDestination = "+15550100", + normalizedDestination = "+15550100", + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStoreImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStoreImplTest.kt new file mode 100644 index 000000000..a2539d388 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStoreImplTest.kt @@ -0,0 +1,155 @@ +package com.android.messaging.data.conversationlist.store + +import android.content.Context +import androidx.core.content.contentValuesOf +import com.android.messaging.FactoryTestAccess +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.SearchableParticipant +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.installTestFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +internal class ConversationListSearchDatabaseStoreImplTest { + + private val context: Context = RuntimeEnvironment.getApplication().applicationContext + private lateinit var db: DatabaseWrapper + private val store = ConversationListSearchDatabaseStoreImpl() + + @Before + fun setUp() { + installTestFactory( + context = context, + dataModel = mockk(relaxed = true) { + every { database } answers { db } + }, + ) + db = createInMemoryActionSyncTestDatabase(context = context) + } + + @After + fun tearDown() { + db.database.close() + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun readParticipants_groupsEachConversationsParticipantsUnderIt() { + val anna = db.insertParticipant( + destination = ANNA_DESTINATION, + fullName = "Anna Smith", + firstName = "Anna", + ) + val bob = db.insertParticipant( + destination = BOB_DESTINATION, + fullName = null, + firstName = null, + ) + val direct = db.insertConversation(participantIds = listOf(anna)) + val group = db.insertConversation(participantIds = listOf(anna, bob)) + + val participants = store.readParticipants() + + assertEquals( + mapOf( + direct to listOf(annaParticipant()), + group to listOf(annaParticipant(), bobParticipant()), + ), + participants, + ) + } + + @Test + fun readParticipants_participantInNoConversation_isLeftOut() { + db.insertParticipant( + destination = ANNA_DESTINATION, + fullName = "Anna Smith", + firstName = "Anna", + ) + + assertTrue(store.readParticipants().isEmpty()) + } + + private fun annaParticipant(): SearchableParticipant { + return SearchableParticipant( + fullName = "Anna Smith", + firstName = "Anna", + displayDestination = ANNA_DESTINATION, + normalizedDestination = ANNA_DESTINATION, + ) + } + + private fun bobParticipant(): SearchableParticipant { + return SearchableParticipant( + fullName = null, + firstName = null, + displayDestination = BOB_DESTINATION, + normalizedDestination = BOB_DESTINATION, + ) + } + + private fun DatabaseWrapper.insertParticipant( + destination: String, + fullName: String?, + firstName: String?, + ): Long { + return insert( + DatabaseHelper.PARTICIPANTS_TABLE, + null, + contentValuesOf( + ParticipantColumns.SUB_ID to ParticipantData.OTHER_THAN_SELF_SUB_ID, + ParticipantColumns.NORMALIZED_DESTINATION to destination, + ParticipantColumns.SEND_DESTINATION to destination, + ParticipantColumns.DISPLAY_DESTINATION to destination, + ParticipantColumns.FULL_NAME to fullName, + ParticipantColumns.FIRST_NAME to firstName, + ), + ) + } + + private fun DatabaseWrapper.insertConversation(participantIds: List): ConversationId { + val conversationId = insert( + DatabaseHelper.CONVERSATIONS_TABLE, + null, + contentValuesOf( + ConversationColumns.PARTICIPANT_COUNT to participantIds.size, + ), + ) + + participantIds.forEach { participantId -> + insert( + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, + null, + contentValuesOf( + ConversationParticipantsColumns.CONVERSATION_ID to conversationId, + ConversationParticipantsColumns.PARTICIPANT_ID to participantId, + ), + ) + } + + return ConversationId(conversationId.toString()) + } + + private companion object { + private const val ANNA_DESTINATION = "+15550100" + private const val BOB_DESTINATION = "+15550101" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/SearchTopAppBarFieldTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/SearchTopAppBarFieldTest.kt new file mode 100644 index 000000000..ae8fe3e6a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/SearchTopAppBarFieldTest.kt @@ -0,0 +1,66 @@ +package com.android.messaging.ui.common.components + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class SearchTopAppBarFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun emptyField_showsTheHintAndTakesFocus() { + setContent(state = TextFieldState()) + + composeTestRule.onNodeWithText(hint()).assertIsDisplayed() + composeTestRule.onNodeWithTag(FIELD_TEST_TAG).assertIsFocused() + } + + @Test + fun typing_updatesTheStateAndHidesTheHint() { + val state = TextFieldState() + setContent(state = state) + + composeTestRule.onNodeWithTag(FIELD_TEST_TAG).performTextInput(QUERY) + + assertEquals(QUERY, state.text.toString()) + composeTestRule.onNodeWithText(hint()).assertDoesNotExist() + } + + private fun setContent(state: TextFieldState) { + composeTestRule.setContent { + AppTheme { + SearchTopAppBarField( + state = state, + hint = R.string.conversation_list_search_hint, + modifier = Modifier.testTag(FIELD_TEST_TAG), + ) + } + } + } + + private fun hint(): String { + return targetContext.getString(R.string.conversation_list_search_hint) + } + + private companion object { + private const val FIELD_TEST_TAG = "search_field" + private const val QUERY = "anna" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/text/HighlightedTextTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/text/HighlightedTextTest.kt new file mode 100644 index 000000000..6f0cda603 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/text/HighlightedTextTest.kt @@ -0,0 +1,55 @@ +package com.android.messaging.ui.common.text + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class HighlightedTextTest { + + @Test + fun buildHighlightedText_stylesEachMatchedRange() { + val text = buildHighlightedText( + text = TEXT, + matchRanges = listOf(0..1, 5..6), + highlightStyle = HIGHLIGHT, + ) + + assertEquals(TEXT, text.text) + assertEquals( + listOf( + AnnotatedString.Range(HIGHLIGHT, 0, 2), + AnnotatedString.Range(HIGHLIGHT, 5, 7), + ), + text.spanStyles, + ) + } + + @Test + fun buildHighlightedText_rangePastTheEnd_isClampedToTheText() { + val text = buildHighlightedText( + text = TEXT, + matchRanges = listOf(8..20), + highlightStyle = HIGHLIGHT, + ) + + assertEquals(listOf(AnnotatedString.Range(HIGHLIGHT, 8, TEXT.length)), text.spanStyles) + } + + @Test + fun buildHighlightedText_emptyOrOutsideRanges_addNoStyle() { + val text = buildHighlightedText( + text = TEXT, + matchRanges = listOf(3..2, 40..45), + highlightStyle = HIGHLIGHT, + ) + + assertEquals(emptyList>(), text.spanStyles) + } + + private companion object { + private const val TEXT = "Anna Smith" + private val HIGHLIGHT = SpanStyle(color = Color.Red) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt index 5d026c115..503957a48 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt @@ -21,8 +21,10 @@ import com.android.messaging.ui.conversationlist.chats.model.ConversationListEff import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState as State import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSearchDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListSearchUiState import com.android.messaging.ui.conversationlist.snapshotOfIds import com.android.messaging.ui.core.AppTheme import io.mockk.every @@ -179,12 +181,16 @@ internal class ConversationListNewestConversationVisibilityTest { val selectionDelegate = mockk(relaxed = true) every { selectionDelegate.selectedIds } returns MutableStateFlow(persistentListOf()) + val searchDelegate = mockk(relaxed = true) + every { searchDelegate.state } returns MutableStateFlow(ConversationListSearchUiState()) + return ConversationListViewModel( repository = repository, uiStateMapper = uiStateMapper, selectionDelegate = selectionDelegate, actionsDelegate = mockk(relaxed = true), optimisticSnapshotDelegate = optimisticSnapshotDelegate, + searchDelegate = searchDelegate, resolveContactAction = mockk(relaxed = true), debugFeaturesProvider = mockk(relaxed = true), conversationArchiveEvents = ConversationArchiveEventsImpl(), diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContentTest.kt new file mode 100644 index 000000000..2e1388b4a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContentTest.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.FactoryTestAccess +import com.android.messaging.R +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.testutil.installTestFactory +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.common.support.CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG +import com.android.messaging.ui.conversationlist.common.support.conversationListItemTestTag +import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.persistentListOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationListSearchContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val emittedActions = mutableListOf() + + @Before + fun setUp() { + installTestFactory(context = targetContext) + } + + @After + fun tearDown() { + FactoryTestAccess.reset() + } + + @Test + fun results_showTheConversationsUnderTheirHeading() { + setContent( + content = ConversationListSearchContentUiState.Results( + conversations = persistentListOf( + previewConversationListItem( + conversationId = CONVERSATION_ID, + title = TITLE, + snippetText = null, + ).copy( + titleMatchRanges = persistentListOf(0..1), + isArchivedBadgeVisible = true, + ), + ), + ), + ) + + composeTestRule.onNodeWithTag(CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG).assertIsDisplayed() + composeTestRule + .onNodeWithText(string(R.string.conversation_list_search_conversations_header)) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(conversationListItemTestTag(CONVERSATION_ID)) + .assertIsDisplayed() + } + + @Test + fun result_clicked_opensItsConversation() { + setContent( + content = ConversationListSearchContentUiState.Results( + conversations = persistentListOf( + previewConversationListItem( + conversationId = CONVERSATION_ID, + title = TITLE, + snippetText = null, + ), + ), + ), + ) + + composeTestRule.onNodeWithTag(conversationListItemTestTag(CONVERSATION_ID)).performClick() + + assertEquals(listOf(Action.SearchConversationClicked(CONVERSATION_ID)), emittedActions) + } + + @Test + fun noResults_saysSo() { + setContent(content = ConversationListSearchContentUiState.NoResults) + + composeTestRule + .onNodeWithText(string(R.string.conversation_list_search_no_results)) + .assertIsDisplayed() + } + + @Test + fun idle_showsNothing() { + setContent(content = ConversationListSearchContentUiState.Idle) + + composeTestRule + .onNodeWithTag(CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(string(R.string.conversation_list_search_no_results)) + .assertDoesNotExist() + } + + private fun setContent(content: ConversationListSearchContentUiState) { + composeTestRule.setContent { + AppTheme { + ConversationListSearchContent( + content = content, + scaffoldContentPadding = PaddingValues(), + onAction = { action -> emittedActions += action }, + ) + } + } + } + + private fun string(resId: Int): String { + return targetContext.getString(resId) + } + + private companion object { + private const val TITLE = "Anna" + private val CONVERSATION_ID = ConversationId("1") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBarTest.kt new file mode 100644 index 000000000..ca684576b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBarTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationListSearchTopAppBarTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun closeButton_clicked_closesSearch() { + var closeClicks = 0 + composeTestRule.setContent { + AppTheme { + ConversationListSearchTopAppBar( + searchState = TextFieldState(), + onCloseClick = { closeClicks++ }, + ) + } + } + + composeTestRule + .onNodeWithContentDescription(string(R.string.conversation_list_search_close)) + .performClick() + + assertEquals(1, closeClicks) + } + + @Test + fun clearButton_withText_clearsTheField() { + val searchState = TextFieldState(initialText = QUERY) + composeTestRule.setContent { + AppTheme { + ConversationListSearchTopAppBar( + searchState = searchState, + onCloseClick = {}, + ) + } + } + + composeTestRule + .onNodeWithContentDescription(string(R.string.conversation_list_search_clear)) + .performClick() + + assertEquals("", searchState.text.toString()) + composeTestRule + .onNodeWithContentDescription(string(R.string.conversation_list_search_clear)) + .assertDoesNotExist() + } + + @Test + fun searchButton_onListTopBar_opensSearch() { + val emittedActions = mutableListOf() + composeTestRule.setContent { + AppTheme { + ConversationListTopAppBar( + hasBlockedParticipants = false, + isDebugEnabled = false, + onAction = { action -> emittedActions += action }, + ) + } + } + + composeTestRule + .onNodeWithContentDescription(string(R.string.conversation_list_search)) + .assertIsDisplayed() + .performClick() + + assertEquals(listOf(Action.SearchClicked), emittedActions) + } + + private fun string(resId: Int): String { + return targetContext.getString(resId) + } + + private companion object { + private const val QUERY = "anna" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt index 3a92622b3..7d727fb9b 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt @@ -14,10 +14,14 @@ import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUi import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect import com.android.messaging.ui.conversationlist.chats.model.ConversationListNavEvent as NavEvent +import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState import com.android.messaging.ui.conversationlist.conversationItem import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSearchDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListSearchUiState import com.android.messaging.ui.conversationlist.snapshotOf import io.mockk.coEvery import io.mockk.coVerify @@ -48,6 +52,7 @@ class ConversationListViewModelTest { private val selectionDelegate = mockk() private val actionsDelegate = mockk() private val optimisticSnapshotDelegate = mockk() + private val searchDelegate = mockk() private val debugFeaturesProvider = mockk() private val resolveContactAction = mockk() private val conversationArchiveEvents = ConversationArchiveEventsImpl() @@ -56,6 +61,7 @@ class ConversationListViewModelTest { private val selectedIdsFlow = MutableStateFlow>( persistentListOf(), ) + private val searchStateFlow = MutableStateFlow(ConversationListSearchUiState()) @Test fun init_bindsDelegates() { @@ -63,6 +69,63 @@ class ConversationListViewModelTest { verify(exactly = 1) { optimisticSnapshotDelegate.bind(any(), any()) } verify(exactly = 1) { selectionDelegate.bind(any(), snapshotFlow) } + verify(exactly = 1) { searchDelegate.bind(any()) } + } + + @Test + fun searchClicked_opensSearch() { + createViewModel().onAction(Action.SearchClicked) + + verify(exactly = 1) { searchDelegate.open() } + } + + @Test + fun searchClosed_closesSearch() { + createViewModel().onAction(Action.SearchClosed) + + verify(exactly = 1) { searchDelegate.close() } + } + + @Test + fun searchQueryChanged_passesTheQueryToSearch() { + createViewModel().onAction(Action.SearchQueryChanged(query = "anna")) + + verify(exactly = 1) { searchDelegate.setQuery("anna") } + } + + @Test + fun searchConversationClicked_emitsOpenConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + viewModel.navigationEvents.test { + viewModel.onAction(Action.SearchConversationClicked(ConversationId("a"))) + + assertThat(awaitItem()).isEqualTo(NavEvent.OpenConversation(ConversationId("a"))) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun uiState_carriesTheSearchState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val searchState = ConversationListSearchUiState( + isActive = true, + content = ConversationListSearchContentUiState.NoResults, + ) + snapshotFlow.value = snapshotOf(conversationItem(ConversationId("a"))) + searchStateFlow.value = searchState + every { + uiStateMapper.map(any(), any(), any(), any(), any()) + } returns ConversationListUiState() + + val viewModel = createViewModel() + viewModel.uiState.test { + assertEquals(ConversationListSearchUiState(), awaitItem().search) + assertEquals(searchState, awaitItem().search) + cancelAndIgnoreRemainingEvents() + } + } } @Test @@ -305,6 +368,12 @@ class ConversationListViewModelTest { every { selectionDelegate.toggle(any()) } just runs every { selectionDelegate.clear() } just runs + every { searchDelegate.state } returns searchStateFlow + every { searchDelegate.bind(any()) } just runs + every { searchDelegate.open() } just runs + every { searchDelegate.close() } just runs + every { searchDelegate.setQuery(any()) } just runs + coEvery { actionsDelegate.setArchived(any(), any()) } just runs coEvery { actionsDelegate.setPinned(any(), any()) } just runs coEvery { actionsDelegate.setRead(any(), any()) } just runs @@ -324,6 +393,7 @@ class ConversationListViewModelTest { selectionDelegate = selectionDelegate, actionsDelegate = actionsDelegate, optimisticSnapshotDelegate = optimisticSnapshotDelegate, + searchDelegate = searchDelegate, debugFeaturesProvider = debugFeaturesProvider, resolveContactAction = resolveContactAction, conversationArchiveEvents = conversationArchiveEvents, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegateImplTest.kt new file mode 100644 index 000000000..85a99affc --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegateImplTest.kt @@ -0,0 +1,262 @@ +package com.android.messaging.ui.conversationlist.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.matcher.ConversationSearchMatcherImpl +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.SearchableConversation +import com.android.messaging.data.conversationlist.repository.ConversationListSearchRepository +import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.mapper.ConversationListItemUiMapper +import com.android.messaging.ui.conversationlist.mapper.ConversationListSearchUiStateMapperImpl +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListSearchUiState +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationListSearchDelegateImplTest { + + private val conversations = MutableStateFlow>( + persistentListOf( + searchable(conversationId = ANNA_ID, title = "Anna"), + searchable(conversationId = BOB_ID, title = "Bob"), + ), + ) + private val repository = mockk { + every { observeSearchableConversations() } returns conversations + } + private val itemUiMapper = mockk { + every { map(any(), any(), any()) } answers { + val item = firstArg() + + previewConversationListItem( + conversationId = item.conversationId, + title = item.title.orEmpty(), + snippetText = null, + ) + } + } + private val savedStateHandle = SavedStateHandle() + + @Test + fun bind_searchClosed_staysInactiveWithoutReadingConversations() { + runTest { + val delegate = createDelegate() + + delegate.bind(scope = backgroundScope) + runCurrent() + + assertEquals(ConversationListSearchUiState(), delegate.state.value) + verify(exactly = 0) { repository.observeSearchableConversations() } + } + } + + @Test + fun open_turnsSearchOnBeforeAnyResultArrives() { + runTest { + val delegate = createDelegate() + delegate.bind(scope = backgroundScope) + + delegate.open() + + assertEquals(ConversationListSearchUiState(isActive = true), delegate.state.value) + assertEquals(true, savedStateHandle.get(SEARCH_ACTIVE_KEY)) + } + } + + @Test + fun setQuery_findsMatchesOnlyOnceTypingPauses() { + runTest { + val delegate = createBoundOpenDelegate() + + delegate.setQuery(query = "ann") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS - 1) + runCurrent() + + assertEquals(ConversationListSearchContentUiState.Idle, delegate.state.value.content) + + advanceTimeBy(delayTimeMillis = 1) + runCurrent() + + assertEquals(listOf(ANNA_ID), resultIds(delegate)) + assertEquals("ann", savedStateHandle.get(SEARCH_QUERY_KEY)) + } + } + + @Test + fun setQuery_cleared_returnsToIdleWithoutWaiting() { + runTest { + val delegate = createBoundOpenDelegate() + delegate.setQuery(query = "ann") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + + delegate.setQuery(query = "") + runCurrent() + + assertEquals(ConversationListSearchContentUiState.Idle, delegate.state.value.content) + } + } + + @Test + fun setQuery_nothingMatches_hasNoResults() { + runTest { + val delegate = createBoundOpenDelegate() + + delegate.setQuery(query = "zed") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + + assertEquals( + ConversationListSearchContentUiState.NoResults, + delegate.state.value.content, + ) + } + } + + @Test + fun conversationsChange_whileSearching_updatesResults() { + runTest { + val delegate = createBoundOpenDelegate() + delegate.setQuery(query = "b") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + + conversations.value = persistentListOf( + searchable(conversationId = BOB_ID, title = "Bob"), + searchable(conversationId = BEA_ID, title = "Bea"), + ) + runCurrent() + + assertEquals(listOf(BOB_ID, BEA_ID), resultIds(delegate)) + } + } + + @Test + fun close_turnsSearchOffAndForgetsTheQuery() { + runTest { + val delegate = createBoundOpenDelegate() + delegate.setQuery(query = "ann") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + + delegate.close() + runCurrent() + + assertEquals(ConversationListSearchUiState(), delegate.state.value) + assertEquals(false, savedStateHandle.get(SEARCH_ACTIVE_KEY)) + assertEquals("", savedStateHandle.get(SEARCH_QUERY_KEY)) + } + } + + @Test + fun openAndClose_repeated_areIgnored() { + runTest { + val delegate = createDelegate() + delegate.bind(scope = backgroundScope) + + delegate.close() + assertFalse(savedStateHandle.contains(SEARCH_ACTIVE_KEY)) + + delegate.open() + delegate.setQuery(query = "ann") + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + delegate.open() + + assertEquals(listOf(ANNA_ID), resultIds(delegate)) + } + } + + @Test + fun bind_twice_observesConversationsOnce() { + runTest { + val delegate = createDelegate() + delegate.open() + + delegate.bind(scope = backgroundScope) + delegate.bind(scope = backgroundScope) + runCurrent() + + verify(exactly = 1) { repository.observeSearchableConversations() } + } + } + + @Test + fun restoredSearch_resumesWithTheSavedQuery() { + runTest { + savedStateHandle[SEARCH_ACTIVE_KEY] = true + savedStateHandle[SEARCH_QUERY_KEY] = "bob" + val delegate = createDelegate() + + assertTrue(delegate.state.value.isActive) + + delegate.bind(scope = backgroundScope) + advanceTimeBy(delayTimeMillis = DEBOUNCE_MILLIS) + runCurrent() + + assertEquals(listOf(BOB_ID), resultIds(delegate)) + } + } + + private fun TestScope.createBoundOpenDelegate(): ConversationListSearchDelegateImpl { + val delegate = createDelegate() + delegate.bind(scope = backgroundScope) + delegate.open() + runCurrent() + + return delegate + } + + private fun TestScope.createDelegate(): ConversationListSearchDelegateImpl { + return ConversationListSearchDelegateImpl( + repository = repository, + matcher = ConversationSearchMatcherImpl(), + uiStateMapper = ConversationListSearchUiStateMapperImpl(itemUiMapper = itemUiMapper), + savedStateHandle = savedStateHandle, + defaultDispatcher = StandardTestDispatcher(testScheduler), + ) + } + + private fun resultIds(delegate: ConversationListSearchDelegate): List { + val content = delegate.state.value.content as ConversationListSearchContentUiState.Results + + return content.conversations.map { item -> item.conversationId } + } + + private fun searchable( + conversationId: ConversationId, + title: String, + ): SearchableConversation { + return SearchableConversation( + item = conversationItem(conversationId = conversationId).copy(title = title), + participants = persistentListOf(), + ) + } + + private companion object { + private const val SEARCH_ACTIVE_KEY = "conversation_list_search_active" + private const val SEARCH_QUERY_KEY = "conversation_list_search_query" + private const val DEBOUNCE_MILLIS = 150L + + private val ANNA_ID = ConversationId("1") + private val BOB_ID = ConversationId("2") + private val BEA_ID = ConversationId("3") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapperImplTest.kt new file mode 100644 index 000000000..a367efd47 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapperImplTest.kt @@ -0,0 +1,104 @@ +package com.android.messaging.ui.conversationlist.mapper + +import android.content.Context +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.ConversationSearchMatch +import com.android.messaging.data.phone.formatter.PhoneNumberFormatter +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import com.android.messaging.util.OsUtil +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.collections.immutable.persistentListOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class ConversationListSearchUiStateMapperImplTest { + + private val mapper = ConversationListSearchUiStateMapperImpl( + itemUiMapper = ConversationListItemUiMapperImpl( + context = mockk(relaxed = true), + canPlacePhoneCall = mockk(relaxed = true), + canShowOrAddContact = mockk(relaxed = true), + isContactSaved = mockk(relaxed = true), + phoneNumberFormatter = mockk(relaxed = true), + resolveAvatarUri = mockk(relaxed = true), + ), + ) + + @Before + fun setUp() { + mockkStatic(OsUtil::class) + every { OsUtil.isSecondaryUser() } returns false + } + + @After + fun tearDown() { + unmockkStatic(OsUtil::class) + } + + @Test + fun map_blankQuery_isIdle() { + val content = mapper.map( + query = " ", + conversationMatches = persistentListOf(match(conversationId = INBOX_ID)), + ) + + assertEquals(ConversationListSearchContentUiState.Idle, content) + } + + @Test + fun map_queryWithoutMatches_hasNoResults() { + val content = mapper.map( + query = QUERY, + conversationMatches = persistentListOf(), + ) + + assertEquals(ConversationListSearchContentUiState.NoResults, content) + } + + @Test + fun map_matches_keepTheirOrderHighlightsAndArchivedBadge() { + val content = mapper.map( + query = QUERY, + conversationMatches = persistentListOf( + match(conversationId = INBOX_ID), + match(conversationId = ARCHIVED_ID, isArchived = true), + ), + ) + + val conversations = (content as ConversationListSearchContentUiState.Results).conversations + assertEquals(listOf(INBOX_ID, ARCHIVED_ID), conversations.map { it.conversationId }) + assertEquals(listOf(TITLE_MATCH), conversations.first().titleMatchRanges) + assertEquals(listOf(false, true), conversations.map { it.isArchivedBadgeVisible }) + assertEquals(listOf(false, false), conversations.map { it.isSelected || it.isOpened }) + } + + private fun match( + conversationId: ConversationId, + isArchived: Boolean = false, + ): ConversationSearchMatch { + return ConversationSearchMatch( + item = conversationItem( + conversationId = conversationId, + isArchived = isArchived, + ), + titleMatchRanges = persistentListOf(TITLE_MATCH), + ) + } + + private companion object { + private const val QUERY = "title" + private val TITLE_MATCH = 0..4 + private val INBOX_ID = ConversationId("1") + private val ARCHIVED_ID = ConversationId("2") + } +} diff --git a/res/values/strings.xml b/res/values/strings.xml index 1a5ed693a..631040182 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -603,6 +603,20 @@ Snoozed Work profile + + Archived + + Search + + Search + + Close search + + Clear search + + Conversations + + No results Show conversation actions for %1$s diff --git a/src/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcher.kt b/src/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcher.kt new file mode 100644 index 000000000..0f050280c --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/matcher/ConversationSearchMatcher.kt @@ -0,0 +1,238 @@ +package com.android.messaging.data.conversationlist.matcher + +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationSearchMatch +import com.android.messaging.data.conversationlist.model.SearchableConversation +import java.text.Normalizer +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal interface ConversationSearchMatcher { + + fun index(conversations: List): ConversationSearchIndex +} + +internal fun interface ConversationSearchIndex { + + fun match(query: String): ImmutableList +} + +internal class ConversationSearchMatcherImpl @Inject constructor() : ConversationSearchMatcher { + + override fun index(conversations: List): ConversationSearchIndex { + // Folding is the costly part, so it runs once per change to the conversations rather + // than once per keystroke + val entries = conversations.map(::indexConversation) + + return ConversationSearchIndex { query -> + matchEntries( + entries = entries, + query = query, + ) + } + } + + private fun indexConversation(conversation: SearchableConversation): IndexedConversation { + val names = conversation.participants + .flatMap { participant -> + listOfNotNull( + participant.fullName, + participant.firstName, + participant.displayDestination, + ) + } + .map { name -> foldText(name).text } + + val destinationDigits = conversation.participants + .mapNotNull { participant -> + participant.normalizedDestination + ?.filter(::isAsciiDigit) + ?.takeIf(String::isNotEmpty) + } + + return IndexedConversation( + item = conversation.item, + title = foldText(conversation.item.title.orEmpty()), + names = names, + destinationDigits = destinationDigits, + ) + } + + private fun matchEntries( + entries: List, + query: String, + ): ImmutableList { + val terms = foldText(query).text + .split(WHITESPACE_REGEX) + .filter(String::isNotEmpty) + + if (terms.isEmpty()) { + return persistentListOf() + } + + val queryDigits = phoneQueryDigitsOrNull(query) + + return entries + .mapNotNull { entry -> + matchEntry( + entry = entry, + terms = terms, + queryDigits = queryDigits, + ) + } + .toImmutableList() + } + + private fun matchEntry( + entry: IndexedConversation, + terms: List, + queryDigits: String?, + ): ConversationSearchMatch? { + // Each word may match a different field, so "anna bob" finds the group they are both in + val matchesNames = terms.all { term -> + term in entry.title.text || entry.names.any { name -> term in name } + } + val matchesDestination = queryDigits != null && + entry.destinationDigits.any { digits -> queryDigits in digits } + + if (!matchesNames && !matchesDestination) { + return null + } + + return ConversationSearchMatch( + item = entry.item, + titleMatchRanges = entry.title.findSourceRanges(terms), + ) + } + + private fun phoneQueryDigitsOrNull(query: String): String? { + val trimmedQuery = query.trim() + val isPhoneQuery = trimmedQuery.all { char -> + isAsciiDigit(char) || char in PHONE_NUMBER_SEPARATORS + } + + return trimmedQuery + .filter(::isAsciiDigit) + .takeIf { digits -> isPhoneQuery && digits.length >= MIN_PHONE_QUERY_DIGITS } + } + + private fun foldText(text: String): FoldedText { + if (text.all { char -> char.code < ASCII_LIMIT }) { + return FoldedText( + text = text.lowercase(), + sourceStarts = null, + sourceLength = text.length, + ) + } + + val folded = StringBuilder(text.length) + val sourceStarts = ArrayList(text.length) + var index = 0 + + // Folding each code point on its own keeps track of where every folded character came + // from, so matches can be highlighted in the original text + while (index < text.length) { + val charCount = Character.charCount(text.codePointAt(index)) + val foldedCodePoint = Normalizer + .normalize(text.substring(index, index + charCount), Normalizer.Form.NFD) + .replace(COMBINING_MARKS_REGEX, "") + .lowercase() + + foldedCodePoint.forEach { char -> + folded.append(char) + sourceStarts.add(index) + } + + index += charCount + } + + return FoldedText( + text = folded.toString(), + sourceStarts = sourceStarts.toIntArray(), + sourceLength = text.length, + ) + } + + private fun isAsciiDigit(char: Char): Boolean { + return char in '0'..'9' + } + + private class IndexedConversation( + val item: ConversationListItem, + val title: FoldedText, + val names: List, + val destinationDigits: List, + ) + + private class FoldedText( + val text: String, + private val sourceStarts: IntArray?, + private val sourceLength: Int, + ) { + + fun findSourceRanges(terms: List): ImmutableList { + val ranges = terms + .flatMap(::findOccurrences) + .sortedBy(IntRange::first) + + return mergeRanges(ranges) + .map(::toSourceRange) + .toImmutableList() + } + + private fun findOccurrences(term: String): List { + return buildList { + var start = text.indexOf(term) + + while (start >= 0) { + add(start until start + term.length) + start = text.indexOf(term, startIndex = start + term.length) + } + } + } + + private fun mergeRanges(sortedRanges: List): List { + val merged = ArrayList(sortedRanges.size) + + sortedRanges.forEach { range -> + val last = merged.lastOrNull() + + when { + last != null && range.first <= last.last + 1 -> { + merged[merged.lastIndex] = last.first..maxOf(last.last, range.last) + } + + else -> merged.add(range) + } + } + + return merged + } + + private fun toSourceRange(range: IntRange): IntRange { + val starts = sourceStarts ?: return range + + val start = starts[range.first] + + // Ending just before the next folded character's source keeps any combining marks + // that trail the last matched character inside the range + val end = when { + range.last + 1 < starts.size -> starts[range.last + 1] - 1 + else -> sourceLength - 1 + } + + return start..maxOf(start, end) + } + } + + private companion object { + private const val ASCII_LIMIT = 0x80 + private const val MIN_PHONE_QUERY_DIGITS = 3 + private const val PHONE_NUMBER_SEPARATORS = "+-(). " + + private val COMBINING_MARKS_REGEX = Regex("\\p{Mn}+") + private val WHITESPACE_REGEX = Regex("\\s+") + } +} diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationSearchMatch.kt b/src/com/android/messaging/data/conversationlist/model/ConversationSearchMatch.kt new file mode 100644 index 000000000..c289937e6 --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/model/ConversationSearchMatch.kt @@ -0,0 +1,8 @@ +package com.android.messaging.data.conversationlist.model + +import kotlinx.collections.immutable.ImmutableList + +internal data class ConversationSearchMatch( + val item: ConversationListItem, + val titleMatchRanges: ImmutableList, +) diff --git a/src/com/android/messaging/data/conversationlist/model/SearchableConversation.kt b/src/com/android/messaging/data/conversationlist/model/SearchableConversation.kt new file mode 100644 index 000000000..3644c4b3e --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/model/SearchableConversation.kt @@ -0,0 +1,15 @@ +package com.android.messaging.data.conversationlist.model + +import kotlinx.collections.immutable.ImmutableList + +internal data class SearchableConversation( + val item: ConversationListItem, + val participants: ImmutableList, +) + +internal data class SearchableParticipant( + val fullName: String?, + val firstName: String?, + val displayDestination: String?, + val normalizedDestination: String?, +) diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepository.kt new file mode 100644 index 000000000..3743202dc --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListSearchRepository.kt @@ -0,0 +1,106 @@ +package com.android.messaging.data.conversationlist.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListMode +import com.android.messaging.data.conversationlist.model.SearchableConversation +import com.android.messaging.data.conversationlist.model.SearchableParticipant +import com.android.messaging.data.conversationlist.store.ConversationListSearchDatabaseStore +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.di.core.MessagingDbDispatcher +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationListSearchRepository { + + /** + * Every conversation search can find, archived ones included and blocked senders left out, + * with the participants whose names and numbers it can be found by. + */ + fun observeSearchableConversations(): Flow> +} + +internal class ConversationListSearchRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val conversationListRepository: ConversationListRepository, + private val databaseStore: ConversationListSearchDatabaseStore, + @param:MessagingDbDispatcher + private val messagingDbDispatcher: CoroutineDispatcher, +) : ConversationListSearchRepository { + + override fun observeSearchableConversations(): Flow> { + return combine( + conversationListRepository.observeSnapshot(mode = ConversationListMode.Inbox), + conversationListRepository.observeSnapshot(mode = ConversationListMode.Archived), + observeParticipants(), + ) { inbox, archived, participantsByConversation -> + // Only the inbox snapshot reads the blocked senders; they apply to both lists + (inbox.items + archived.items) + .filterNot { item -> + item.isFromBlockedSender(blockedDestinations = inbox.blockedDestinations) + } + .map { item -> + SearchableConversation( + item = item, + participants = participantsByConversation[item.conversationId] + ?: persistentListOf(), + ) + } + .toImmutableList() + } + } + + private fun observeParticipants(): + Flow>> { + return observeUris( + uris = listOf( + MessagingContentProvider.CONVERSATIONS_URI, + MessagingContentProvider.PARTICIPANTS_URI, + ), + ) + .conflate() + .map { databaseStore.readParticipants() } + .flowOn(messagingDbDispatcher) + } + + private fun ConversationListItem.isFromBlockedSender( + blockedDestinations: Set, + ): Boolean { + return !participant.isGroup && + participant.otherNormalizedDestination in blockedDestinations + } + + private fun observeUris(uris: List): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + uris.forEach { uri -> + contentResolver.registerContentObserver(uri, true, observer) + } + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } +} diff --git a/src/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStore.kt b/src/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStore.kt new file mode 100644 index 000000000..1688c1d6f --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/store/ConversationListSearchDatabaseStore.kt @@ -0,0 +1,71 @@ +package com.android.messaging.data.conversationlist.store + +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversationlist.model.SearchableParticipant +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.util.db.ext.getStringOrNull +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap + +internal interface ConversationListSearchDatabaseStore { + + fun readParticipants(): ImmutableMap> +} + +internal class ConversationListSearchDatabaseStoreImpl @Inject constructor() : + ConversationListSearchDatabaseStore { + + override fun readParticipants(): + ImmutableMap> { + val participantsByConversation = + LinkedHashMap>() + + DataModel.get().database.rawQuery(PARTICIPANTS_SQL, null).use { cursor -> + while (cursor.moveToNext()) { + val conversationId = ConversationId.fromOrNull( + value = cursor.getStringOrNull(ConversationParticipantsColumns.CONVERSATION_ID), + ) ?: continue + + participantsByConversation + .getOrPut(conversationId, ::mutableListOf) + .add( + SearchableParticipant( + fullName = cursor.getStringOrNull(ParticipantColumns.FULL_NAME), + firstName = cursor.getStringOrNull(ParticipantColumns.FIRST_NAME), + displayDestination = cursor.getStringOrNull( + ParticipantColumns.DISPLAY_DESTINATION, + ), + normalizedDestination = cursor.getStringOrNull( + ParticipantColumns.NORMALIZED_DESTINATION, + ), + ), + ) + } + } + + return participantsByConversation + .mapValues { (_, participants) -> participants.toImmutableList() } + .toImmutableMap() + } + + private companion object { + private val PARTICIPANTS_SQL = """ + SELECT member.${ConversationParticipantsColumns.CONVERSATION_ID}, + participant.${ParticipantColumns.FULL_NAME}, + participant.${ParticipantColumns.FIRST_NAME}, + participant.${ParticipantColumns.DISPLAY_DESTINATION}, + participant.${ParticipantColumns.NORMALIZED_DESTINATION} + FROM ${DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE} AS member + JOIN ${DatabaseHelper.PARTICIPANTS_TABLE} AS participant + ON participant.${ParticipantColumns._ID} = + member.${ConversationParticipantsColumns.PARTICIPANT_ID} + ORDER BY member.${ConversationParticipantsColumns._ID} + """.trimIndent() + } +} diff --git a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt index 2ba09c2cf..d928e3c66 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt @@ -1,7 +1,13 @@ package com.android.messaging.di.conversationlist +import com.android.messaging.data.conversationlist.matcher.ConversationSearchMatcher +import com.android.messaging.data.conversationlist.matcher.ConversationSearchMatcherImpl import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationlist.repository.ConversationListRepositoryImpl +import com.android.messaging.data.conversationlist.repository.ConversationListSearchRepository +import com.android.messaging.data.conversationlist.repository.ConversationListSearchRepositoryImpl +import com.android.messaging.data.conversationlist.store.ConversationListSearchDatabaseStore +import com.android.messaging.data.conversationlist.store.ConversationListSearchDatabaseStoreImpl import com.android.messaging.data.conversationlist.store.ConversationListStatusStore import com.android.messaging.data.conversationlist.store.ConversationListStatusStoreImpl import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper @@ -10,6 +16,8 @@ import com.android.messaging.ui.conversationlist.mapper.ConversationListContentU import com.android.messaging.ui.conversationlist.mapper.ConversationListContentUiStateMapperImpl import com.android.messaging.ui.conversationlist.mapper.ConversationListItemUiMapper import com.android.messaging.ui.conversationlist.mapper.ConversationListItemUiMapperImpl +import com.android.messaging.ui.conversationlist.mapper.ConversationListSearchUiStateMapper +import com.android.messaging.ui.conversationlist.mapper.ConversationListSearchUiStateMapperImpl import dagger.Binds import dagger.Module import dagger.Reusable @@ -49,4 +57,28 @@ internal abstract class ConversationListBindsModule { abstract fun bindConversationListContentUiStateMapper( impl: ConversationListContentUiStateMapperImpl, ): ConversationListContentUiStateMapper + + @Binds + @Reusable + abstract fun bindConversationListSearchRepository( + impl: ConversationListSearchRepositoryImpl, + ): ConversationListSearchRepository + + @Binds + @Reusable + abstract fun bindConversationListSearchDatabaseStore( + impl: ConversationListSearchDatabaseStoreImpl, + ): ConversationListSearchDatabaseStore + + @Binds + @Reusable + abstract fun bindConversationSearchMatcher( + impl: ConversationSearchMatcherImpl, + ): ConversationSearchMatcher + + @Binds + @Reusable + abstract fun bindConversationListSearchUiStateMapper( + impl: ConversationListSearchUiStateMapperImpl, + ): ConversationListSearchUiStateMapper } diff --git a/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt index 66dd1c3e7..a1074c018 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt @@ -4,6 +4,8 @@ import com.android.messaging.ui.conversationlist.delegate.ConversationListAction import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegateImpl import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegateImpl +import com.android.messaging.ui.conversationlist.delegate.ConversationListSearchDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSearchDelegateImpl import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegateImpl import dagger.Binds @@ -33,4 +35,10 @@ internal abstract class ConversationListViewModelBindsModule { abstract fun bindConversationListOptimisticSnapshotDelegate( impl: ConversationListOptimisticSnapshotDelegateImpl, ): ConversationListOptimisticSnapshotDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationListSearchDelegate( + impl: ConversationListSearchDelegateImpl, + ): ConversationListSearchDelegate } diff --git a/src/com/android/messaging/ui/common/components/SearchTopAppBarField.kt b/src/com/android/messaging/ui/common/components/SearchTopAppBarField.kt new file mode 100644 index 000000000..b6d7af018 --- /dev/null +++ b/src/com/android/messaging/ui/common/components/SearchTopAppBarField.kt @@ -0,0 +1,75 @@ +package com.android.messaging.ui.common.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.core.MessagingPreviewTheme + +@Composable +internal fun SearchTopAppBarField( + state: TextFieldState, + @StringRes hint: Int, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + BasicTextField( + state = state, + modifier = modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + lineLimits = TextFieldLineLimits.SingleLine, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + decorator = { innerTextField -> + Box { + if (state.text.isEmpty()) { + Text( + text = stringResource(id = hint), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + }, + ) +} + +@PreviewLightDark +@Composable +private fun SearchTopAppBarFieldPreview() { + MessagingPreviewTheme { + SearchTopAppBarField( + state = remember { TextFieldState() }, + hint = R.string.conversation_list_search_hint, + ) + } +} diff --git a/src/com/android/messaging/ui/common/text/HighlightedText.kt b/src/com/android/messaging/ui/common/text/HighlightedText.kt new file mode 100644 index 000000000..288548c2f --- /dev/null +++ b/src/com/android/messaging/ui/common/text/HighlightedText.kt @@ -0,0 +1,51 @@ +package com.android.messaging.ui.common.text + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight + +@Composable +internal fun highlightedText( + text: String, + matchRanges: List, +): AnnotatedString { + val highlightColor = MaterialTheme.colorScheme.primary + + return remember(text, matchRanges, highlightColor) { + buildHighlightedText( + text = text, + matchRanges = matchRanges, + highlightStyle = SpanStyle( + color = highlightColor, + fontWeight = FontWeight.Bold, + ), + ) + } +} + +internal fun buildHighlightedText( + text: String, + matchRanges: List, + highlightStyle: SpanStyle, +): AnnotatedString { + return buildAnnotatedString { + append(text) + + matchRanges.forEach { range -> + val start = range.first.coerceIn(0, text.length) + val end = (range.last + 1).coerceIn(start, text.length) + + if (start < end) { + addStyle( + style = highlightStyle, + start = start, + end = end, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListFabs.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListFabs.kt new file mode 100644 index 000000000..1bf594bb5 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListFabs.kt @@ -0,0 +1,123 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Chat +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.common.components.ComposeBarControlHeight +import com.android.messaging.ui.common.components.ComposeBarVerticalPadding +import com.android.messaging.ui.common.components.LocalIsListDetailPane +import com.android.messaging.ui.common.components.PrimaryActionButton +import com.android.messaging.ui.common.components.horizontalSafeDrawingInsets +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState as State +import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState + +private val FabSpacing = 16.dp + +@Composable +internal fun BoxScope.ConversationListFabs( + uiState: State, + isSelectionMode: Boolean, + onAction: (Action) -> Unit, + onScrollToTop: () -> Unit, +) { + val hasItems = uiState.content is ConversationListContentUiState.Items + val fabWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) + val horizontalInsets = horizontalSafeDrawingInsets() + val bottomSpacing = when { + LocalIsListDetailPane.current -> ComposeBarVerticalPadding + else -> FabSpacing + } + + ScrollToTopFab( + visible = uiState.isScrollToTopVisible, + onClick = onScrollToTop, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(fabWindowInsets) + .padding(bottom = bottomSpacing), + ) + + StartChatFab( + visible = hasItems && !isSelectionMode, + expanded = !uiState.isScrollToTopVisible, + onClick = { onAction(Action.StartChatClicked) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding(fabWindowInsets) + .padding(horizontalInsets) + .padding(horizontal = FabSpacing) + .padding(bottom = bottomSpacing), + ) +} + +@Composable +private fun ScrollToTopFab( + visible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + SmallFloatingActionButton( + onClick = onClick, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ) { + Icon( + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = stringResource(R.string.conversation_list_scroll_to_top), + ) + } + } +} + +@Composable +private fun StartChatFab( + visible: Boolean, + expanded: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + PrimaryActionButton( + text = stringResource(R.string.conversation_list_start_chat), + onClick = onClick, + modifier = Modifier.height(height = ComposeBarControlHeight), + expanded = expanded, + leadingIcon = Icons.AutoMirrored.Rounded.Chat, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt index e75d93e20..a5f4c8332 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt @@ -2,32 +2,18 @@ package com.android.messaging.ui.conversationlist.chats import android.content.Context import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Chat -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material3.Icon +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,7 +24,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -52,10 +37,6 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.data.conversation.model.ConversationId -import com.android.messaging.ui.common.components.ComposeBarControlHeight -import com.android.messaging.ui.common.components.ComposeBarVerticalPadding -import com.android.messaging.ui.common.components.LocalIsListDetailPane -import com.android.messaging.ui.common.components.PrimaryActionButton import com.android.messaging.ui.common.components.contentSurfaceShape import com.android.messaging.ui.common.components.horizontalSafeDrawingInsets import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimation @@ -78,7 +59,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -private val FabSpacing = 16.dp private val FabBottomReserve = 72.dp @Composable @@ -396,11 +376,18 @@ private fun ConversationListScaffold( ) { val isSelectionMode = uiState.selection.selectedCount > 0 val backdropColor = conversationListBackdropColor(isSelectionMode) + val searchState = rememberTextFieldState() BackHandler(enabled = isSelectionMode) { onAction(Action.SelectionCleared) } + ConversationListSearchEffects( + searchState = searchState, + isBackHandlerEnabled = uiState.search.isActive && !isSelectionMode, + onAction = onAction, + ) + ConversationListScrollReporter( listState = listState, onAction = onAction, @@ -413,6 +400,7 @@ private fun ConversationListScaffold( ConversationListTopBar( uiState = uiState, isSelectionMode = isSelectionMode, + searchState = searchState, onAction = onAction, onDeleteClick = onDeleteClick, onSnoozeClick = onSnoozeClick, @@ -436,19 +424,12 @@ private fun ConversationListScaffold( .clip(MaterialTheme.contentSurfaceShape) .background(listPaneContentColor()), ) { - ConversationListContent( - content = uiState.content, + ConversationListPaneContent( + uiState = uiState, listState = listState, - onAction = onAction, - scaffoldContentPadding = contentPadding, + contentPadding = contentPadding, isSelectionMode = isSelectionMode, - fabBottomReserve = FabBottomReserve, pinAnimationController = pinAnimationController, - ) - - ConversationListFabs( - uiState = uiState, - isSelectionMode = isSelectionMode, onAction = onAction, onScrollToTop = onScrollToTop, ) @@ -457,40 +438,43 @@ private fun ConversationListScaffold( } @Composable -private fun BoxScope.ConversationListFabs( +private fun BoxScope.ConversationListPaneContent( uiState: State, + listState: LazyListState, + contentPadding: PaddingValues, isSelectionMode: Boolean, + pinAnimationController: OverlayReorderAnimationController?, onAction: (Action) -> Unit, onScrollToTop: () -> Unit, ) { - val hasItems = uiState.content is ConversationListContentUiState.Items - val fabWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom) - val horizontalInsets = horizontalSafeDrawingInsets() - val bottomSpacing = when { - LocalIsListDetailPane.current -> ComposeBarVerticalPadding - else -> FabSpacing - } + when { + uiState.search.isActive -> { + ConversationListSearchContent( + content = uiState.search.content, + scaffoldContentPadding = contentPadding, + onAction = onAction, + ) + } - ScrollToTopFab( - visible = uiState.isScrollToTopVisible, - onClick = onScrollToTop, - modifier = Modifier - .align(Alignment.BottomCenter) - .windowInsetsPadding(fabWindowInsets) - .padding(bottom = bottomSpacing), - ) + else -> { + ConversationListContent( + content = uiState.content, + listState = listState, + onAction = onAction, + scaffoldContentPadding = contentPadding, + isSelectionMode = isSelectionMode, + fabBottomReserve = FabBottomReserve, + pinAnimationController = pinAnimationController, + ) - StartChatFab( - visible = hasItems && !isSelectionMode, - expanded = !uiState.isScrollToTopVisible, - onClick = { onAction(Action.StartChatClicked) }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding(fabWindowInsets) - .padding(horizontalInsets) - .padding(horizontal = FabSpacing) - .padding(bottom = bottomSpacing), - ) + ConversationListFabs( + uiState = uiState, + isSelectionMode = isSelectionMode, + onAction = onAction, + onScrollToTop = onScrollToTop, + ) + } + } } @Composable @@ -505,6 +489,7 @@ private fun conversationListBackdropColor(isSelectionMode: Boolean): Color { private fun ConversationListTopBar( uiState: State, isSelectionMode: Boolean, + searchState: TextFieldState, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, onSnoozeClick: () -> Unit, @@ -520,6 +505,18 @@ private fun ConversationListTopBar( ) } + uiState.search.isActive -> { + ConversationListSearchTopAppBar( + searchState = searchState, + onCloseClick = { + closeConversationListSearch( + searchState = searchState, + onAction = onAction, + ) + }, + ) + } + else -> { ConversationListTopAppBar( hasBlockedParticipants = uiState.hasBlockedParticipants, @@ -547,54 +544,6 @@ private fun ConversationListScrollReporter( } } -@Composable -private fun ScrollToTopFab( - visible: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - modifier = modifier, - visible = visible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - ) { - SmallFloatingActionButton( - onClick = onClick, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ) { - Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = stringResource(R.string.conversation_list_scroll_to_top), - ) - } - } -} - -@Composable -private fun StartChatFab( - visible: Boolean, - expanded: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - modifier = modifier, - visible = visible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - ) { - PrimaryActionButton( - text = stringResource(R.string.conversation_list_start_chat), - onClick = onClick, - modifier = Modifier.height(height = ComposeBarControlHeight), - expanded = expanded, - leadingIcon = Icons.AutoMirrored.Rounded.Chat, - ) - } -} - @PreviewLightDark @Composable private fun ConversationListScaffoldItemsPreview() { diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContent.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContent.kt new file mode 100644 index 000000000..f37e4943b --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchContent.kt @@ -0,0 +1,196 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.common.components.horizontalSafeDrawingInsets +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.common.item.ConversationListItemRow +import com.android.messaging.ui.conversationlist.common.item.SearchResultsContentPadding +import com.android.messaging.ui.conversationlist.common.item.SearchResultsItemSpacing +import com.android.messaging.ui.conversationlist.common.item.SearchSectionHeaderHorizontalPadding +import com.android.messaging.ui.conversationlist.common.item.SearchSectionHeaderVerticalPadding +import com.android.messaging.ui.conversationlist.common.list.conversationRowHorizontalPadding +import com.android.messaging.ui.conversationlist.common.status.ConversationListStatusMessage +import com.android.messaging.ui.conversationlist.common.support.CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG +import com.android.messaging.ui.conversationlist.common.support.previewConversationListItems +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel as Model +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +private const val CONVERSATIONS_HEADER_KEY = "conversations_header" +private const val SECTION_HEADER_CONTENT_TYPE = "section_header" +private const val CONVERSATION_ROW_CONTENT_TYPE = "conversation_row" + +@Composable +internal fun ConversationListSearchContent( + content: ConversationListSearchContentUiState, + scaffoldContentPadding: PaddingValues, + onAction: (Action) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + when (content) { + ConversationListSearchContentUiState.Idle -> Unit + + ConversationListSearchContentUiState.NoResults -> { + ConversationListStatusMessage( + text = stringResource(R.string.conversation_list_search_no_results), + ) + } + + is ConversationListSearchContentUiState.Results -> { + ConversationListSearchResults( + conversations = content.conversations, + scaffoldContentPadding = scaffoldContentPadding, + onAction = onAction, + ) + } + } + } +} + +@Composable +private fun ConversationListSearchResults( + conversations: ImmutableList, + scaffoldContentPadding: PaddingValues, + onAction: (Action) -> Unit, +) { + val horizontalInsets = horizontalSafeDrawingInsets() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG), + contentPadding = PaddingValues( + top = SearchResultsContentPadding, + bottom = scaffoldContentPadding.calculateBottomPadding() + + SearchResultsContentPadding, + ), + verticalArrangement = Arrangement.spacedBy(SearchResultsItemSpacing), + ) { + item( + key = CONVERSATIONS_HEADER_KEY, + contentType = SECTION_HEADER_CONTENT_TYPE, + ) { + ConversationListSearchSectionHeader( + text = stringResource(R.string.conversation_list_search_conversations_header), + horizontalInsets = horizontalInsets, + ) + } + + items( + items = conversations, + key = { item -> item.conversationId.value }, + contentType = { CONVERSATION_ROW_CONTENT_TYPE }, + ) { item -> + ConversationListSearchConversationRow( + item = item, + horizontalInsets = horizontalInsets, + onAction = onAction, + ) + } + } +} + +@Composable +private fun ConversationListSearchSectionHeader( + text: String, + horizontalInsets: PaddingValues, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier + .padding(horizontalInsets) + .padding( + horizontal = SearchSectionHeaderHorizontalPadding, + vertical = SearchSectionHeaderVerticalPadding, + ) + .semantics { heading() }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun ConversationListSearchConversationRow( + item: Model, + horizontalInsets: PaddingValues, + onAction: (Action) -> Unit, +) { + val destination = item.avatar.normalizedDestination + + ConversationListItemRow( + item = item, + modifier = Modifier.conversationRowHorizontalPadding(horizontalInsets), + onClick = { + onAction(Action.SearchConversationClicked(item.conversationId)) + }, + onLongClick = {}, + onAvatarMessageClick = { + onAction(Action.SearchConversationClicked(item.conversationId)) + }, + onAvatarCallClick = { + if (destination != null) { + onAction(Action.AvatarCallClicked(destination)) + } + }.takeIf { item.avatar.canCall }, + onAvatarContactClick = { + onAction(Action.AvatarContactClicked(item.avatar)) + }.takeIf { item.avatar.canShowContact }, + onAvatarInfoClick = { + onAction(Action.AvatarInfoClicked(item.conversationId)) + }, + ) +} + +@PreviewLightDark +@Composable +private fun ConversationListSearchContentResultsPreview() { + MessagingPreviewTheme { + ConversationListSearchContent( + content = ConversationListSearchContentUiState.Results( + conversations = previewConversationListItems() + .map { item -> + item.copy( + titleMatchRanges = persistentListOf(0..1), + isArchivedBadgeVisible = item.isPinned, + ) + } + .toImmutableList(), + ), + scaffoldContentPadding = PaddingValues(), + onAction = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListSearchContentNoResultsPreview() { + MessagingPreviewTheme { + ConversationListSearchContent( + content = ConversationListSearchContentUiState.NoResults, + scaffoldContentPadding = PaddingValues(), + onAction = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchEffects.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchEffects.kt new file mode 100644 index 000000000..8957582fe --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchEffects.kt @@ -0,0 +1,42 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action + +@Composable +internal fun ConversationListSearchEffects( + searchState: TextFieldState, + isBackHandlerEnabled: Boolean, + onAction: (Action) -> Unit, +) { + val currentOnAction by rememberUpdatedState(onAction) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .collect { query -> + currentOnAction(Action.SearchQueryChanged(query)) + } + } + + BackHandler(enabled = isBackHandlerEnabled) { + closeConversationListSearch( + searchState = searchState, + onAction = onAction, + ) + } +} + +internal fun closeConversationListSearch( + searchState: TextFieldState, + onAction: (Action) -> Unit, +) { + searchState.clearText() + onAction(Action.SearchClosed) +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBar.kt new file mode 100644 index 000000000..b0c3b6901 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSearchTopAppBar.kt @@ -0,0 +1,76 @@ +package com.android.messaging.ui.conversationlist.chats + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.common.components.SearchTopAppBarField +import com.android.messaging.ui.conversationlist.common.support.CONVERSATION_LIST_SEARCH_FIELD_TEST_TAG +import com.android.messaging.ui.core.MessagingPreviewTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationListSearchTopAppBar( + searchState: TextFieldState, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.conversation_list_search_close), + ) + } + }, + title = { + SearchTopAppBarField( + state = searchState, + hint = R.string.conversation_list_search_hint, + modifier = Modifier.testTag(CONVERSATION_LIST_SEARCH_FIELD_TEST_TAG), + ) + }, + actions = { + if (searchState.text.isNotEmpty()) { + IconButton(onClick = { searchState.clearText() }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + R.string.conversation_list_search_clear, + ), + ) + } + } + }, + ) +} + +@PreviewLightDark +@Composable +private fun ConversationListSearchTopAppBarPreview() { + MessagingPreviewTheme { + ConversationListSearchTopAppBar( + searchState = remember { TextFieldState(initialText = "Anna") }, + onCloseClick = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListTopAppBar.kt index 309ecc0a6..63748b146 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListTopAppBar.kt @@ -2,7 +2,11 @@ package com.android.messaging.ui.conversationlist.chats import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -44,6 +48,13 @@ internal fun ConversationListTopAppBar( ) }, actions = { + IconButton(onClick = { onAction(Action.SearchClicked) }) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.conversation_list_search), + ) + } + ConversationListOverflowMenu( hasBlockedParticipants = hasBlockedParticipants, isDebugEnabled = isDebugEnabled, diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index c73f09363..857cf879f 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -22,6 +22,7 @@ import com.android.messaging.ui.conversationlist.chats.model.ConversationListNav import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState as State import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSearchDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -57,6 +58,7 @@ internal class ConversationListViewModel @Inject constructor( private val selectionDelegate: ConversationListSelectionDelegate, private val actionsDelegate: ConversationListActionsDelegate, private val optimisticSnapshotDelegate: ConversationListOptimisticSnapshotDelegate, + private val searchDelegate: ConversationListSearchDelegate, private val resolveContactAction: ResolveContactAction, private val debugFeaturesProvider: DebugFeaturesProvider, private val conversationArchiveEvents: ConversationArchiveEvents, @@ -82,19 +84,24 @@ internal class ConversationListViewModel @Inject constructor( override val navigationEvents: Flow = _navigationEvents.receiveAsFlow() override val uiState: StateFlow = combine( - snapshot.filterNotNull(), - selectionDelegate.selectedIds, - openedConversationId, - isScrollToTopVisible, - isDebugEnabled, - ) { snapshot, selectedIds, openedId, isScrollToTopVisible, isDebugEnabled -> - uiStateMapper.map( - snapshot = snapshot, - selectedConversationIds = selectedIds, - openedConversationId = openedId, - isScrollToTopVisible = isScrollToTopVisible, - isDebugEnabled = isDebugEnabled, - ) + combine( + snapshot.filterNotNull(), + selectionDelegate.selectedIds, + openedConversationId, + isScrollToTopVisible, + isDebugEnabled, + ) { snapshot, selectedIds, openedId, isScrollToTopVisible, isDebugEnabled -> + uiStateMapper.map( + snapshot = snapshot, + selectedConversationIds = selectedIds, + openedConversationId = openedId, + isScrollToTopVisible = isScrollToTopVisible, + isDebugEnabled = isDebugEnabled, + ) + }, + searchDelegate.state, + ) { state, search -> + state.copy(search = search) } .flowOn(defaultDispatcher) .stateIn( @@ -114,6 +121,7 @@ internal class ConversationListViewModel @Inject constructor( scope = viewModelScope, snapshot = snapshot, ) + searchDelegate.bind(scope = viewModelScope) offerUndoForArchivesOutsideList() } @@ -126,6 +134,7 @@ internal class ConversationListViewModel @Inject constructor( is Action.SelectionAction -> onSelectionAction(action) is Action.SnackbarAction -> onSnackbarAction(action) is Action.PaneStateAction -> onPaneStateAction(action) + is Action.SearchAction -> onSearchAction(action) } } @@ -176,6 +185,26 @@ internal class ConversationListViewModel @Inject constructor( } } + private fun onSearchAction(action: Action.SearchAction) { + when (action) { + Action.SearchClicked -> { + searchDelegate.open() + } + + Action.SearchClosed -> { + searchDelegate.close() + } + + is Action.SearchQueryChanged -> { + searchDelegate.setQuery(action.query) + } + + is Action.SearchConversationClicked -> { + _navigationEvents.trySend(NavEvent.OpenConversation(action.conversationId)) + } + } + } + private fun onBlockConfirmed( conversationId: ConversationId, destination: String, diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt index 59185338e..c9f7b299e 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt @@ -21,6 +21,8 @@ internal sealed interface ConversationListAction { sealed interface PaneStateAction : ConversationListAction + sealed interface SearchAction : ConversationListAction + // region ConfirmationAction data class BlockConfirmed( val conversationId: ConversationId, @@ -125,4 +127,17 @@ internal sealed interface ConversationListAction { val conversationId: ConversationId?, ) : PaneStateAction // endregion + + // region SearchAction + data object SearchClicked : SearchAction + data object SearchClosed : SearchAction + + data class SearchQueryChanged( + val query: String, + ) : SearchAction + + data class SearchConversationClicked( + val conversationId: ConversationId, + ) : SearchAction + // endregion } diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt index ed3e57090..d6aa4b8ea 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt @@ -2,11 +2,13 @@ package com.android.messaging.ui.conversationlist.chats.model import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListSearchUiState @Immutable internal data class ConversationListUiState( val content: ConversationListContentUiState = ConversationListContentUiState.Loading, val selection: ConversationListSelectionUiState = ConversationListSelectionUiState(), + val search: ConversationListSearchUiState = ConversationListSearchUiState(), val isScrollToTopVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, val isDebugEnabled: Boolean = false, diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemContentDescription.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemContentDescription.kt index 939997d46..93fca5246 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemContentDescription.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemContentDescription.kt @@ -114,6 +114,10 @@ internal fun conversationListItemBadgeContentDescriptionResIds( if (item.isPinned) { add(R.string.conversation_list_status_pinned) } + + if (item.isArchivedBadgeVisible) { + add(R.string.conversation_list_status_archived) + } } } diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt index e6276d19e..0fd8130ed 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsPaused import androidx.compose.material.icons.filled.PushPin @@ -33,6 +34,7 @@ import com.android.messaging.R import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.ui.common.components.TwoLineListItem +import com.android.messaging.ui.common.text.highlightedText import com.android.messaging.ui.conversationlist.common.pane.listPaneHighlightedItemColor import com.android.messaging.ui.conversationlist.common.support.conversationListItemTestTag import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem @@ -98,7 +100,10 @@ private fun ConversationListItemHeader(item: ConversationListItemUiModel) { weight = 1f, fill = false, ), - text = item.title.orEmpty(), + text = highlightedText( + text = item.title.orEmpty(), + matchRanges = item.titleMatchRanges, + ), style = MaterialTheme.typography.bodyLarge, fontWeight = itemUnreadFontWeight(item), color = MaterialTheme.colorScheme.onSurface, @@ -140,6 +145,10 @@ private fun ConversationListItemBadges(item: ConversationListItemUiModel) { if (item.isPinned) { ConversationListItemBadgeIcon(Icons.Default.PushPin) } + + if (item.isArchivedBadgeVisible) { + ConversationListItemBadgeIcon(Icons.Default.Archive) + } } } diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListTokens.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListTokens.kt index 75bc4fe47..0ae84dcbd 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListTokens.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListTokens.kt @@ -15,3 +15,11 @@ internal val ItemStatusDotSize = 12.dp internal val ItemPreviewThumbnailSize = 20.dp internal val ItemPreviewThumbnailCornerRadius = 4.dp + +internal val SearchResultsContentPadding = 8.dp + +internal val SearchResultsItemSpacing = 2.dp + +internal val SearchSectionHeaderHorizontalPadding = 16.dp + +internal val SearchSectionHeaderVerticalPadding = 8.dp diff --git a/src/com/android/messaging/ui/conversationlist/common/support/ConversationListTestTags.kt b/src/com/android/messaging/ui/conversationlist/common/support/ConversationListTestTags.kt index 737c19bdd..e289df238 100644 --- a/src/com/android/messaging/ui/conversationlist/common/support/ConversationListTestTags.kt +++ b/src/com/android/messaging/ui/conversationlist/common/support/ConversationListTestTags.kt @@ -4,6 +4,10 @@ import com.android.messaging.data.conversation.model.ConversationId internal const val CONVERSATION_LIST_TEST_TAG = "conversation_list" +internal const val CONVERSATION_LIST_SEARCH_FIELD_TEST_TAG = "conversation_list_search_field" + +internal const val CONVERSATION_LIST_SEARCH_RESULTS_TEST_TAG = "conversation_list_search_results" + internal fun conversationListItemTestTag(conversationId: ConversationId): String { return "conversation_list_item_${conversationId.value}" } diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegate.kt new file mode 100644 index 000000000..96715fbc3 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSearchDelegate.kt @@ -0,0 +1,147 @@ +package com.android.messaging.ui.conversationlist.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.conversationlist.matcher.ConversationSearchMatcher +import com.android.messaging.data.conversationlist.repository.ConversationListSearchRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversationlist.mapper.ConversationListSearchUiStateMapper +import com.android.messaging.ui.conversationlist.model.ConversationListSearchUiState +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach + +internal interface ConversationListSearchDelegate { + val state: StateFlow + + fun bind(scope: CoroutineScope) + fun open() + fun close() + fun setQuery(query: String) +} + +internal class ConversationListSearchDelegateImpl @Inject constructor( + private val repository: ConversationListSearchRepository, + private val matcher: ConversationSearchMatcher, + private val uiStateMapper: ConversationListSearchUiStateMapper, + private val savedStateHandle: SavedStateHandle, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationListSearchDelegate { + + private val isActive = MutableStateFlow( + value = savedStateHandle.get(SEARCH_ACTIVE_KEY) == true, + ) + private val query = MutableStateFlow( + value = savedStateHandle.get(SEARCH_QUERY_KEY).orEmpty(), + ) + + private val _state = MutableStateFlow( + value = ConversationListSearchUiState(isActive = isActive.value), + ) + override val state: StateFlow = _state.asStateFlow() + + private var isBound = false + + @OptIn(ExperimentalCoroutinesApi::class) + override fun bind(scope: CoroutineScope) { + if (isBound) { + return + } + + isBound = true + + isActive + .flatMapLatest { active -> + when { + active -> observeActiveSearch() + else -> flowOf(ConversationListSearchUiState()) + } + } + .flowOn(defaultDispatcher) + .onEach { searchState -> + // open() and close() swap the state themselves, so a result still in flight + // from before the swap must not undo it + if (searchState.isActive == isActive.value) { + _state.value = searchState + } + } + .launchIn(scope) + } + + override fun open() { + if (isActive.value) { + return + } + + // Swap the top bar right away instead of after the first results arrive + _state.value = ConversationListSearchUiState(isActive = true) + setActive(isActive = true) + } + + override fun close() { + if (!isActive.value) { + return + } + + _state.value = ConversationListSearchUiState() + setQuery(query = "") + setActive(isActive = false) + } + + override fun setQuery(query: String) { + this.query.value = query + savedStateHandle[SEARCH_QUERY_KEY] = query + } + + private fun setActive(isActive: Boolean) { + this.isActive.value = isActive + savedStateHandle[SEARCH_ACTIVE_KEY] = isActive + } + + private fun observeActiveSearch(): Flow { + return combine( + repository.observeSearchableConversations().map(matcher::index), + debouncedQuery(), + ) { searchIndex, currentQuery -> + ConversationListSearchUiState( + isActive = true, + content = uiStateMapper.map( + query = currentQuery, + conversationMatches = searchIndex.match(currentQuery), + ), + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun debouncedQuery(): Flow { + return query.mapLatest { currentQuery -> + // A cleared query empties the results at once; only typing waits for a pause + if (currentQuery.isNotBlank()) { + delay(SEARCH_DEBOUNCE_MILLIS) + } + + currentQuery + } + } + + private companion object { + private const val SEARCH_ACTIVE_KEY = "conversation_list_search_active" + private const val SEARCH_QUERY_KEY = "conversation_list_search_query" + private const val SEARCH_DEBOUNCE_MILLIS = 150L + } +} diff --git a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapper.kt new file mode 100644 index 000000000..87b7fa0e0 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListSearchUiStateMapper.kt @@ -0,0 +1,52 @@ +package com.android.messaging.ui.conversationlist.mapper + +import com.android.messaging.data.conversationlist.model.ConversationSearchMatch +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListSearchContentUiState +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal interface ConversationListSearchUiStateMapper { + fun map( + query: String, + conversationMatches: ImmutableList, + ): ConversationListSearchContentUiState +} + +internal class ConversationListSearchUiStateMapperImpl @Inject constructor( + private val itemUiMapper: ConversationListItemUiMapper, +) : ConversationListSearchUiStateMapper { + + override fun map( + query: String, + conversationMatches: ImmutableList, + ): ConversationListSearchContentUiState { + return when { + query.isBlank() -> ConversationListSearchContentUiState.Idle + + conversationMatches.isEmpty() -> ConversationListSearchContentUiState.NoResults + + else -> { + ConversationListSearchContentUiState.Results( + conversations = conversationMatches + .map(::mapConversation) + .toImmutableList(), + ) + } + } + } + + private fun mapConversation(match: ConversationSearchMatch): ConversationListItemUiModel { + return itemUiMapper + .map( + item = match.item, + isSelected = false, + isOpened = false, + ) + .copy( + titleMatchRanges = match.titleMatchRanges, + isArchivedBadgeVisible = match.item.isArchived, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt index 0a18e0280..601b6e56b 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationListItemUiModel( @@ -24,6 +26,8 @@ internal data class ConversationListItemUiModel( val isPinned: Boolean, val isSelected: Boolean, val isOpened: Boolean, + val titleMatchRanges: ImmutableList = persistentListOf(), + val isArchivedBadgeVisible: Boolean = false, ) @Immutable diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListSearchUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListSearchUiState.kt new file mode 100644 index 000000000..5dd5c4fee --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListSearchUiState.kt @@ -0,0 +1,25 @@ +package com.android.messaging.ui.conversationlist.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationListSearchUiState( + val isActive: Boolean = false, + val content: ConversationListSearchContentUiState = ConversationListSearchContentUiState.Idle, +) + +@Immutable +internal sealed interface ConversationListSearchContentUiState { + + @Immutable + data object Idle : ConversationListSearchContentUiState + + @Immutable + data object NoResults : ConversationListSearchContentUiState + + @Immutable + data class Results( + val conversations: ImmutableList, + ) : ConversationListSearchContentUiState +} diff --git a/src/com/android/messaging/ui/conversationpicker/common/PickerTopAppBar.kt b/src/com/android/messaging/ui/conversationpicker/common/PickerTopAppBar.kt index 21f3e0c2c..3ef470d1e 100644 --- a/src/com/android/messaging/ui/conversationpicker/common/PickerTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationpicker/common/PickerTopAppBar.kt @@ -1,11 +1,6 @@ package com.android.messaging.ui.conversationpicker.common import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material.icons.Icons @@ -20,16 +15,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import com.android.messaging.R +import com.android.messaging.ui.common.components.SearchTopAppBarField @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -125,9 +114,9 @@ private fun PickerTopAppBarTitle( ) { when { isSearchActive -> { - PickerSearchField( + SearchTopAppBarField( state = searchState, - searchHint = searchHint, + hint = searchHint, ) } @@ -216,42 +205,3 @@ private fun PickerTopAppBarActions( } } } - -@Composable -private fun PickerSearchField( - state: TextFieldState, - @StringRes searchHint: Int, -) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - BasicTextField( - state = state, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - textStyle = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - lineLimits = TextFieldLineLimits.SingleLine, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - decorator = { innerTextField -> - Box { - if (state.text.isEmpty()) { - Text( - text = stringResource(id = searchHint), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - innerTextField() - } - }, - ) -}