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

Commit a32ab9f0 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury Committed by Fahim M. Choudhury
Browse files

chore: add SearchBar with suggestions in SearchScreen

parent 31ebd4d2
Loading
Loading
Loading
Loading
+75 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.ui.compose.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme

@Composable
fun SearchPlaceholder(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Icon(
                imageVector = Icons.Outlined.Search,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f),
                modifier = Modifier
                    .padding(bottom = 4.dp)
                    .size(72.dp),
            )
            Text(
                text = stringResource(id = R.string.search_hint),
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f),
            )
        }
    }
}

@Preview(showBackground = false)
@Composable
private fun SearchPlaceholderPreview() {
    AppTheme(darkTheme = true) {
        SearchPlaceholder()
    }
}
+158 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

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

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import foundation.e.apps.ui.compose.components.SearchResultsContent
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import foundation.e.apps.ui.search.v2.SearchUiState

@Composable
fun SearchScreen(
    uiState: SearchUiState,
    onQueryChange: (String) -> Unit,
    onBackClick: () -> Unit,
    onClearQuery: () -> Unit,
    onSubmitSearch: (String) -> Unit,
    onSuggestionSelect: (String) -> Unit,
    onTabSelect: (SearchTabType) -> Unit,
    modifier: Modifier = Modifier,
) {
    val focusManager = LocalFocusManager.current
    val keyboardController = LocalSoftwareKeyboardController.current
    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 selectedTab = uiState.selectedTab
    val showSuggestions = isSearchExpanded && uiState.isSuggestionVisible
    val showResults =
        uiState.hasSubmittedSearch && selectedTab != null && uiState.availableTabs.isNotEmpty()

    LaunchedEffect(lifecycleOwner, shouldAutoFocus) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
            if (shouldAutoFocus && !hasRequestedInitialFocus) {
                hasRequestedInitialFocus = true
                withFrameNanos {
                    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.
        }
    }

    Scaffold(
        modifier = modifier,
        topBar = {
            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,
            )
        },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
        ) {
            when {
                showResults && selectedTab != null -> {
                    SearchResultsContent(
                        tabs = uiState.availableTabs,
                        selectedTab = selectedTab,
                        resultsByTab = uiState.resultsByTab,
                        onTabSelect = onTabSelect,
                        modifier = Modifier.fillMaxSize(),
                    )
                }

                else -> {
                    // Suggestions render in the top bar dropdown; leave body empty.
                }
            }
        }
    }
}

@Preview(showBackground = false)
@Composable
private fun SearchScreenPreview() {
    AppTheme(darkTheme = true) {
        SearchScreen(
            uiState = SearchUiState(
                query = "telegram",
                suggestions = listOf("telegram", "telegram messenger")
            ),
            onQueryChange = {},
            onBackClick = {},
            onClearQuery = {},
            onSubmitSearch = {},
            onSuggestionSelect = {},
            onTabSelect = {},
        )
    }
}
+216 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
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.material3.surfaceColorAtElevation
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
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import foundation.e.apps.R
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import foundation.e.apps.ui.search.v2.SearchUiState

@OptIn(ExperimentalMaterial3Api::class)
@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,
) {
    SearchBar(
        modifier = modifier.fillMaxWidth(),
        colors = SearchBarDefaults.colors(
            containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
            dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
        ),
        expanded = expanded,
        onExpandedChange = { isExpanded ->
            onExpandedChange(isExpanded)
        },
        inputField = {
            SearchBarDefaults.InputField(
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(focusRequester),
                query = uiState.query,
                onQueryChange = { query ->
                    onExpandedChange(true)
                    onQueryChange(query)
                },
                onSearch = { query ->
                    onExpandedChange(false)
                    focusManager.clearFocus()
                    onSearchSubmit(query)
                },
                expanded = expanded,
                onExpandedChange = { isExpanded ->
                    onExpandedChange(isExpanded)
                },
                placeholder = { Text(text = stringResource(id = R.string.search_hint)) },
                leadingIcon = {
                    IconButton(onClick = {
                        focusManager.clearFocus()
                        onBack()
                    }) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = null,
                        )
                    }
                },
                trailingIcon = {
                    if (uiState.query.isNotEmpty()) {
                        IconButton(onClick = {
                            onClearQuery()
                            onExpandedChange(true)
                            focusRequester.requestFocus()
                        }) {
                            Icon(
                                imageVector = Icons.Filled.Close,
                                contentDescription = null,
                            )
                        }
                    }
                },
            )
        },
    ) {
        if (showSuggestions) {
            SuggestionList(
                suggestions = uiState.suggestions,
                onSuggestionSelect = { suggestion ->
                    onExpandedChange(false)
                    focusManager.clearFocus()
                    onSuggestionSelect(suggestion)
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)),
            )
        }
    }
}

@Composable
private fun SuggestionList(
    suggestions: List<String>,
    onSuggestionSelect: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(
        modifier = modifier,
    ) {
        items(
            items = suggestions,
            key = { suggestion -> suggestion },
        ) { suggestion ->
            ListItem(
                headlineContent = { Text(text = suggestion) },
                leadingContent = {
                    Icon(
                        imageVector = Icons.Filled.Search,
                        contentDescription = null,
                        tint = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onSuggestionSelect(suggestion) },
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
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(
                "browser",
                "browser apps",
                "browser downloader",
                "browser for android",
            ),
            isSuggestionVisible = true,
            availableTabs = listOf(
                SearchTabType.COMMON_APPS,
                SearchTabType.OPEN_SOURCE,
                SearchTabType.WEB_APPS,
            ),
            selectedTab = SearchTabType.OPEN_SOURCE,
            hasSubmittedSearch = false,
        )

        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 },
            onBack = {},
        )
    }
}
+20 −1
Original line number Diff line number Diff line
@@ -20,19 +20,38 @@ package foundation.e.apps.ui.search.v2

import android.os.Bundle
import android.view.View
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.R
import foundation.e.apps.ui.compose.screens.SearchScreen
import foundation.e.apps.ui.compose.theme.AppTheme

@AndroidEntryPoint
class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {

    private val searchViewModel: SearchViewModelV2 by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val composeView = view.findViewById<ComposeView>(R.id.composeView)
        composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        composeView.setContent {
            AppTheme { }
            AppTheme {
                val uiState by searchViewModel.uiState.collectAsStateWithLifecycle()
                SearchScreen(
                    uiState = uiState,
                    onQueryChange = searchViewModel::onQueryChanged,
                    onBackClick = { requireActivity().onBackPressedDispatcher.onBackPressed() },
                    onClearQuery = searchViewModel::onQueryCleared,
                    onSubmitSearch = searchViewModel::onSearchSubmitted,
                    onSuggestionSelect = searchViewModel::onSuggestionSelected,
                    onTabSelect = searchViewModel::onTabSelected,
                )
            }
        }
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val SUGGESTION_DEBOUNCE_MS = 200L
private const val SUGGESTION_DEBOUNCE_MS = 500L
private const val FAKE_RESULTS_PER_TAB = 6

enum class SearchTabType {