Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8a9ef397 authored by Saalim Quadri's avatar Saalim Quadri Committed by Nishith Khanna
Browse files

feat: Show SearchResults in main content area

parent 152499b6
Loading
Loading
Loading
Loading
+5 −97
Original line number Diff line number Diff line
@@ -42,6 +42,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.search.v2.SearchUiState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -53,16 +54,13 @@ class SearchTopBarTest {
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun typingQuery_expandsSearchBar_andUpdatesQueryState() {
    fun typingQuery_updatesQueryState() {
        val recorder = SearchTopBarRecorder()
        val hintText = composeRule.activity.getString(R.string.search_hint)

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "",
                suggestions = emptyList(),
                initialExpanded = false,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -78,21 +76,17 @@ class SearchTopBarTest {
            .performTextInput("camera")

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(true))
            assertTrue(recorder.queryChanges.contains("camera"))
            assertEquals("camera", recorder.queryChanges.lastOrNull())
        }
    }

    @Test
    fun submitQuery_collapsesSearchBar_andClearsFocus() {
    fun submitQuery_clearsFocus_andCallsSubmit() {
        val recorder = SearchTopBarRecorder()

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "vpn",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -103,7 +97,6 @@ class SearchTopBarTest {
        inputField.performImeAction()

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(false))
            assertTrue(recorder.searchSubmissions.contains("vpn"))
        }

@@ -112,15 +105,12 @@ class SearchTopBarTest {
    }

    @Test
    fun clearButton_clearsQuery_keepsExpanded_andFocusesInput() {
    fun clearButton_clearsQuery_andFocusesInput() {
        val recorder = SearchTopBarRecorder()

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "maps",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -134,7 +124,6 @@ class SearchTopBarTest {

        composeRule.runOnIdle {
            assertTrue(recorder.clearTapped)
            assertTrue(recorder.expandedChanges.contains(true))
        }

        composeRule.onNodeWithTag(SearchTopBarTestTags.INPUT_FIELD)
@@ -148,9 +137,6 @@ class SearchTopBarTest {
        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "news",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -169,75 +155,11 @@ class SearchTopBarTest {
        composeRule.onNodeWithTag(SearchTopBarTestTags.INPUT_FIELD)
            .assertIsNotFocused()
    }

    @Test
    fun suggestions_callbackFires_whenSuggestionSelected() {
        // Material3 SearchBar renders dropdown in a popup window inaccessible to standard
        // Compose test APIs. This test verifies the callback logic is wired correctly
        // by simulating a suggestion selection through the callback.
        val recorder = SearchTopBarRecorder()
        val suggestions = listOf("camera", "camera apps", "camera pro")
        var simulateSuggestionClick: ((String) -> Unit)? = null

        composeRule.setContent {
            var query by remember { mutableStateOf("cam") }
            var expanded by remember { mutableStateOf(true) }
            val focusRequester = remember { FocusRequester() }
            val focusManager = LocalFocusManager.current

            // Capture the suggestion select callback for manual invocation
            simulateSuggestionClick = { suggestion ->
                expanded = false
                focusManager.clearFocus()
                recorder.suggestionSelections.add(suggestion)
                recorder.expandedChanges.add(false)
            }

            SearchTopBar(
                uiState = SearchUiState(
                    query = query,
                    suggestions = suggestions,
                ),
                expanded = expanded,
                showSuggestions = expanded,
                focusRequester = focusRequester,
                focusManager = focusManager,
                onQueryChange = { query = it },
                onClearQuery = { query = "" },
                onSearchSubmit = {},
                onSuggestionSelect = { suggestion ->
                    expanded = false
                    focusManager.clearFocus()
                    recorder.suggestionSelections.add(suggestion)
                    recorder.expandedChanges.add(false)
                },
                onExpandedChange = { expanded = it },
                onBack = {},
                modifier = Modifier.fillMaxSize(),
            )
        }

        // Verify the SearchBar is displayed and expanded
        composeRule.onNodeWithTag(SearchTopBarTestTags.SEARCH_BAR)
            .assertIsDisplayed()

        // Simulate suggestion selection via callback
        composeRule.runOnIdle {
            simulateSuggestionClick?.invoke("camera apps")
        }

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(false))
            assertTrue(recorder.suggestionSelections.contains("camera apps"))
        }
    }
}

private class SearchTopBarRecorder {
    val queryChanges = mutableListOf<String>()
    val searchSubmissions = mutableListOf<String>()
    val suggestionSelections = mutableListOf<String>()
    val expandedChanges = mutableListOf<Boolean>()
    var clearTapped = false
    var backTapped = false
}
@@ -245,23 +167,16 @@ private class SearchTopBarRecorder {
@Composable
private fun SearchTopBarTestContent(
    initialQuery: String,
    suggestions: List<String>,
    initialExpanded: Boolean,
    showSuggestions: Boolean,
    recorder: SearchTopBarRecorder,
) {
    var query by remember { mutableStateOf(initialQuery) }
    var expanded by remember { mutableStateOf(initialExpanded) }
    val focusRequester = remember { FocusRequester() }
    val focusManager = LocalFocusManager.current

    SearchTopBar(
        uiState = SearchUiState(
            query = query,
            suggestions = suggestions,
        ),
        expanded = expanded,
        showSuggestions = showSuggestions && expanded,
        focusRequester = focusRequester,
        focusManager = focusManager,
        onQueryChange = { updatedQuery ->
@@ -275,13 +190,6 @@ private fun SearchTopBarTestContent(
        onSearchSubmit = { submittedQuery ->
            recorder.searchSubmissions.add(submittedQuery)
        },
        onSuggestionSelect = { suggestion ->
            recorder.suggestionSelections.add(suggestion)
        },
        onExpandedChange = { isExpanded ->
            expanded = isExpanded
            recorder.expandedChanges.add(isExpanded)
        },
        onBack = {
            recorder.backTapped = true
        },
+2 −2
Original line number Diff line number Diff line
@@ -51,7 +51,7 @@ fun SearchInitialState(modifier: Modifier = Modifier) {
            Icon(
                imageVector = Icons.Outlined.Search,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f),
                tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
                modifier = Modifier
                    .padding(bottom = 4.dp)
                    .size(72.dp),
@@ -59,7 +59,7 @@ fun SearchInitialState(modifier: Modifier = Modifier) {
            Text(
                text = stringResource(id = R.string.search_hint),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f),
                color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
            )
        }
    }
+73 −44
Original line number Diff line number Diff line
@@ -18,13 +18,19 @@

package foundation.e.apps.ui.compose.screens

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -80,9 +86,7 @@ fun SearchScreen(
    val lifecycleOwner = LocalLifecycleOwner.current
    val focusRequester = remember { FocusRequester() }
    val shouldAutoFocus = !uiState.hasSubmittedSearch
    var isSearchExpanded by rememberSaveable { mutableStateOf(shouldAutoFocus) }
    var hasRequestedInitialFocus by rememberSaveable { mutableStateOf(false) }
    val showSuggestions = isSearchExpanded && uiState.isSuggestionVisible

    LaunchedEffect(lifecycleOwner, shouldAutoFocus) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
@@ -92,18 +96,13 @@ fun SearchScreen(
                    focusRequester.requestFocus()
                    keyboardController?.show()
                }
            } else {
                // Intentionally no-op; the initial focus request has already happened.
            }
        }
    }

    LaunchedEffect(uiState.hasSubmittedSearch) {
        if (uiState.hasSubmittedSearch) {
            isSearchExpanded = false
            focusManager.clearFocus()
        } else {
            // Intentionally no-op; results are not active so focus remains unchanged.
        }
    }

@@ -113,25 +112,17 @@ fun SearchScreen(
            Column {
                SearchTopBar(
                    uiState = uiState,
                    expanded = isSearchExpanded,
                    showSuggestions = showSuggestions,
                    focusRequester = focusRequester,
                    focusManager = focusManager,
                    onQueryChange = onQueryChange,
                    onClearQuery = onClearQuery,
                    onSearchSubmit = onSubmitSearch,
                    onSuggestionSelect = onSuggestionSelect,
                    onExpandedChange = { expanded ->
                        isSearchExpanded = expanded
                        if (expanded) {
                            focusRequester.requestFocus()
                        } else {
                            focusManager.clearFocus()
                        }
                    },
                    onBack = onBackClick,
                )
                HorizontalDivider(color = MaterialTheme.colorScheme.tertiary)
                HorizontalDivider(
                    color = MaterialTheme.colorScheme.tertiary,
                    modifier = Modifier.padding(top = 8.dp),
                )
            }
        },
    ) { innerPadding ->
@@ -146,8 +137,11 @@ fun SearchScreen(

            val shouldShowResults =
                uiState.hasSubmittedSearch && uiState.selectedTab != null && uiState.availableTabs.isNotEmpty()
            val shouldShowSuggestions =
                !uiState.hasSubmittedSearch && uiState.isSuggestionVisible && uiState.suggestions.isNotEmpty()

            if (shouldShowResults) {
            when {
                shouldShowResults -> {
                    SearchResultsContent(
                        tabs = uiState.availableTabs,
                        selectedTab = uiState.selectedTab!!,
@@ -167,12 +161,47 @@ fun SearchScreen(
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                    )
            } else {
                }
                shouldShowSuggestions -> {
                    SuggestionList(
                        suggestions = uiState.suggestions,
                        onSuggestionSelect = { suggestion ->
                            focusManager.clearFocus()
                            onSuggestionSelect(suggestion)
                        },
                        modifier = Modifier.fillMaxWidth(),
                    )
                }
                else -> {
                    SearchInitialState(
                    modifier = Modifier
                        .fillMaxWidth(),
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }
        }
    }
}

@Composable
private fun SuggestionList(
    suggestions: List<String>,
    onSuggestionSelect: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier = modifier) {
        itemsIndexed(
            items = suggestions,
            key = { _, suggestion -> suggestion },
        ) { _, suggestion ->
            ListItem(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onSuggestionSelect(suggestion) },
                colors = ListItemDefaults.colors(
                    containerColor = MaterialTheme.colorScheme.background,
                ),
                headlineContent = { Text(text = suggestion) },
            )
        }
    }
}
+12 −81
Original line number Diff line number Diff line
@@ -18,28 +18,20 @@

package foundation.e.apps.ui.compose.screens

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@@ -59,15 +51,11 @@ import foundation.e.apps.ui.search.v2.SearchUiState
@Composable
fun SearchTopBar(
    uiState: SearchUiState,
    expanded: Boolean,
    showSuggestions: Boolean,
    focusRequester: FocusRequester,
    focusManager: FocusManager,
    onQueryChange: (String) -> Unit,
    onClearQuery: () -> Unit,
    onSearchSubmit: (String) -> Unit,
    onSuggestionSelect: (String) -> Unit,
    onExpandedChange: (Boolean) -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
@@ -79,13 +67,11 @@ fun SearchTopBar(
        shadowElevation = 0.dp,
        tonalElevation = 0.dp,
        colors = SearchBarDefaults.colors(
            containerColor = MaterialTheme.colorScheme.surface,
            containerColor = MaterialTheme.colorScheme.background,
            dividerColor = MaterialTheme.colorScheme.tertiary,
        ),
        expanded = expanded,
        onExpandedChange = { isExpanded ->
            onExpandedChange(isExpanded)
        },
        expanded = false,
        onExpandedChange = {},
        inputField = {
            SearchBarDefaults.InputField(
                modifier = Modifier
@@ -93,22 +79,16 @@ fun SearchTopBar(
                    .focusRequester(focusRequester)
                    .testTag(SearchTopBarTestTags.INPUT_FIELD),
                query = uiState.query,
                onQueryChange = { query ->
                    onExpandedChange(true)
                    onQueryChange(query)
                },
                onQueryChange = onQueryChange,
                onSearch = { query ->
                    onExpandedChange(false)
                    focusManager.clearFocus()
                    onSearchSubmit(query)
                },
                expanded = expanded,
                onExpandedChange = { isExpanded ->
                    onExpandedChange(isExpanded)
                },
                expanded = false,
                onExpandedChange = {},
                colors = SearchBarDefaults.inputFieldColors(
                    unfocusedContainerColor = MaterialTheme.colorScheme.surface,
                    focusedContainerColor = MaterialTheme.colorScheme.surface,
                    unfocusedContainerColor = MaterialTheme.colorScheme.background,
                    focusedContainerColor = MaterialTheme.colorScheme.background,
                    cursorColor = MaterialTheme.colorScheme.tertiary,
                ),
                placeholder = { Text(text = stringResource(id = R.string.search_hint)) },
@@ -133,7 +113,6 @@ fun SearchTopBar(
                            modifier = Modifier.testTag(SearchTopBarTestTags.CLEAR_BUTTON),
                            onClick = {
                                onClearQuery()
                                onExpandedChange(true)
                                focusRequester.requestFocus()
                            },
                        ) {
@@ -146,48 +125,7 @@ fun SearchTopBar(
                },
            )
        },
    ) {
        if (showSuggestions) {
            SuggestionList(
                suggestions = uiState.suggestions,
                onSuggestionSelect = { suggestion ->
                    onExpandedChange(false)
                    focusManager.clearFocus()
                    onSuggestionSelect(suggestion)
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colorScheme.surface),
            )
        }
    }
}

@Composable
private fun SuggestionList(
    suggestions: List<String>,
    onSuggestionSelect: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(
        modifier = modifier.testTag(SearchTopBarTestTags.SUGGESTIONS_LIST),
    ) {
        itemsIndexed(
            items = suggestions,
            key = { _, suggestion -> suggestion },
        ) { index, suggestion ->
            ListItem(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onSuggestionSelect(suggestion) }
                    .testTag("${SearchTopBarTestTags.SUGGESTION_ITEM_PREFIX}$index"),
                headlineContent = { Text(text = suggestion) },
                leadingContent = {
                    // No-op but we like the space :)
                },
            )
        }
    }
    ) {}
}

internal object SearchTopBarTestTags {
@@ -195,8 +133,6 @@ internal object SearchTopBarTestTags {
    const val INPUT_FIELD = "search_top_bar_input_field"
    const val BACK_BUTTON = "search_top_bar_back_button"
    const val CLEAR_BUTTON = "search_top_bar_clear_button"
    const val SUGGESTIONS_LIST = "search_top_bar_suggestions_list"
    const val SUGGESTION_ITEM_PREFIX = "search_top_bar_suggestion_"
}

@Preview(showBackground = true)
@@ -205,7 +141,6 @@ private fun SearchTopBarPreview() {
    AppTheme(darkTheme = true) {
        val focusRequester = remember { FocusRequester() }
        val focusManager = LocalFocusManager.current
        var expanded by remember { mutableStateOf(true) }
        val sampleState = SearchUiState(
            query = "browser",
            suggestions = listOf(
@@ -226,15 +161,11 @@ private fun SearchTopBarPreview() {

        SearchTopBar(
            uiState = sampleState,
            expanded = expanded,
            showSuggestions = expanded && sampleState.suggestions.isNotEmpty(),
            focusRequester = focusRequester,
            focusManager = focusManager,
            onQueryChange = { expanded = true },
            onClearQuery = { expanded = true },
            onSearchSubmit = { expanded = false },
            onSuggestionSelect = { expanded = false },
            onExpandedChange = { expanded = it },
            onQueryChange = {},
            onClearQuery = {},
            onSearchSubmit = {},
            onBack = {},
        )
    }