commit 44a92b3987da6b9fba2ff256d2b731ba12c9aead parent 060da280e4ff3b730d88c8c20079273754eca6df Author: Harrison Oglesby <oglesby.harrison@gmail.com> Date: Tue, 21 Oct 2025 17:42:21 +0000 Bug 1989459 - Create SettingsSearchFragment, SettingsSearchStore and other Store components for SettingsSearch r=android-reviewers,delphine,petru,007 Differential Revision: https://phabricator.services.mozilla.com/D265891 Diffstat:
10 files changed, 524 insertions(+), 1 deletion(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -221,7 +221,9 @@ class SettingsFragment : PreferenceFragmentCompat() { showToolbarWithIconButton( title = toolbarTitle, iconResId = R.drawable.ic_search, - onClick = { }, + onClick = { + findNavController().navigate(R.id.action_settingsFragment_to_settingsSearchFragment) + }, ) } else { showToolbar(toolbarTitle) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchAction.kt @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import mozilla.components.lib.state.Action + +/** + * Actions for the settings search screen. + */ +sealed interface SettingsSearchAction : Action { + /** + * User has updated the search query in the search bar. + * + * @property query New search query [String]. + */ + data class SearchQueryUpdated(val query: String) : SettingsSearchAction + + /** + * Current Search query yields zero results. + * + * @property query Current search query [String]. + */ + data class NoResultsFound(val query: String) : SettingsSearchAction + + /** + * Search Results have been loaded. + * + * @property query Current search query [String]. + * @property results List of [SettingsSearchItem]s that match the current search query. + */ + data class SearchResultsLoaded( + val query: String, + val results: List<SettingsSearchItem>, + ) : SettingsSearchAction +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.mozilla.fenix.settings.settingssearch + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import androidx.navigation.fragment.findNavController +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Fragment for the settings search screen. + */ +class SettingsSearchFragment : Fragment() { + + lateinit var settingsSearchStore: SettingsSearchStore + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + settingsSearchStore = buildSettingsSearchStore() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = content { + (activity as? AppCompatActivity)?.supportActionBar?.hide() + FirefoxTheme { + SettingsSearchScreen( + store = settingsSearchStore, + onBackClick = { + findNavController().popBackStack() + }, + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + (activity as? AppCompatActivity)?.supportActionBar?.show() + } + + private fun buildSettingsSearchStore(): SettingsSearchStore { + return StoreProvider.get(this) { + SettingsSearchStore( + initialState = SettingsSearchState.Default, + middleware = listOf( + SettingsSearchMiddleware( + SettingsSearchMiddleware.Companion.Dependencies( + context = requireContext(), + ), + ), + ), + ) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import android.content.Context +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext + +/** + * [Middleware] for the settings search screen. + * + * @param initialDependencies [Dependencies] for the middleware. + */ +class SettingsSearchMiddleware( + initialDependencies: Dependencies, +) : Middleware<SettingsSearchState, SettingsSearchAction> { + var dependencies = initialDependencies + + override fun invoke( + context: MiddlewareContext<SettingsSearchState, SettingsSearchAction>, + next: (SettingsSearchAction) -> Unit, + action: SettingsSearchAction, + ) { + when (action) { + is SettingsSearchAction.SearchQueryUpdated -> { + next(action) + } + else -> { + next(action) + // no op + } + } + } + + companion object { + data class Dependencies(val context: Context) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt @@ -0,0 +1,159 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import mozilla.components.compose.base.button.IconButton +import mozilla.components.compose.base.textfield.TextField +import mozilla.components.lib.state.ext.observeAsComposableState +import org.mozilla.fenix.R +import org.mozilla.fenix.theme.FirefoxTheme +import mozilla.components.ui.icons.R as iconsR + +/** + * Composable for the settings search screen. + * + * @param store [SettingsSearchStore] for the screen. + * @param onBackClick Callback for when the back button is clicked. + */ +@Composable +fun SettingsSearchScreen( + store: SettingsSearchStore, + onBackClick: () -> Unit, +) { + val state by store.observeAsComposableState { it } + Scaffold( + topBar = { + SettingsSearchBar( + query = state.searchQuery, + onSearchQueryChanged = { + store.dispatch(SettingsSearchAction.SearchQueryUpdated(it)) + }, + onClearSearchClicked = { + store.dispatch(SettingsSearchAction.SearchQueryUpdated("")) + }, + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier.padding(paddingValues), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.settings_search_empty_query_placeholder), + style = FirefoxTheme.typography.body2, + color = FirefoxTheme.colors.textSecondary, + ) + } + } + } +} + +/** + * Composable for the settings search bar. + * + * @param query Current search query [String]. + * @param onSearchQueryChanged Callback for when the search query changes. + * @param onClearSearchClicked Callback for when the clear search button is clicked. + * @param onBackClick Callback for when the back button is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsSearchBar( + query: String, + onSearchQueryChanged: (String) -> Unit, + onClearSearchClicked: () -> Unit, + onBackClick: () -> Unit, +) { + TopAppBar( + title = { + TextField( + value = query, + onValueChange = { value -> + onSearchQueryChanged(value) + }, + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp), + placeholder = stringResource(R.string.settings_search_title), + singleLine = true, + errorText = stringResource(R.string.settings_search_error_message), + trailingIcons = { + if (query.isNotBlank()) { + IconButton( + onClick = onClearSearchClicked, + contentDescription = stringResource( + R.string.content_description_settings_search_clear_search, + ), + ) { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_cross_24), + contentDescription = null, + tint = FirefoxTheme.colors.textPrimary, + ) + } + } + }, + ) + }, + navigationIcon = { + IconButton( + onClick = onBackClick, + contentDescription = + stringResource( + R.string.content_description_settings_search_navigate_back, + ), + ) { + Icon( + painter = painterResource( + iconsR.drawable.mozac_ic_back_24, + ), + contentDescription = null, + tint = FirefoxTheme.colors.textPrimary, + ) + } + }, + windowInsets = WindowInsets( + top = 0.dp, + bottom = 0.dp, + ), + ) +} + +/** + * Preview for the settings search screen initial state. + */ +@PreviewLightDark +@Composable +private fun SettingsSearchScreenInitialStatePreview() { + FirefoxTheme { + SettingsSearchScreen( + store = SettingsSearchStore(), + onBackClick = {}, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStore.kt @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Store for the settings search screen. + * + * @param initialState Initial state of the store. + * @param middleware List of [Middleware] to apply to the store. + */ +class SettingsSearchStore( + initialState: SettingsSearchState = SettingsSearchState.Default, + middleware: List<Middleware<SettingsSearchState, SettingsSearchAction>> = emptyList(), +) : Store<SettingsSearchState, SettingsSearchAction>( + initialState = initialState, + reducer = ::reduce, + middleware = middleware, +) + +/** + * Reducer for the settings search screen. + */ +private fun reduce(state: SettingsSearchState, action: SettingsSearchAction): SettingsSearchState { + return when (action) { + is SettingsSearchAction.SearchQueryUpdated -> { + if (action.query.isBlank()) { + SettingsSearchState.Default + } else { + SettingsSearchState.SearchInProgress( + searchQuery = action.query, + searchResults = state.searchResults, + ) + } + } + is SettingsSearchAction.NoResultsFound -> { + SettingsSearchState.NoSearchResults( + searchQuery = action.query, + ) + } + is SettingsSearchAction.SearchResultsLoaded -> { + SettingsSearchState.SearchInProgress( + searchQuery = action.query, + searchResults = action.results, + ) + } + } +} + +/** + * Data class representing the state of the settings search screen. + * + * @property searchQuery Current search query [String]. + * @property searchResults List of [SettingsSearchItem]s that match the current search query, if any. + */ +sealed class SettingsSearchState( + open val searchQuery: String = "", + open val searchResults: List<SettingsSearchItem> = emptyList(), +) : State { + /** + * Default state. + * No query, no results + */ + data object Default : SettingsSearchState() + + /** + * State when there is a query. + * Query, results. + * + * @property searchQuery Current search query [String]. + * @property searchResults List of [SettingsSearchItem]s that match the current search query. + */ + data class SearchInProgress( + override val searchQuery: String, + override val searchResults: List<SettingsSearchItem>, + ) : SettingsSearchState(searchQuery, searchResults) + + /** + * State when there is a query but it yields zero search reuslts. + * Query, no results. + * + * @property searchQuery Current search query [String]. + */ + data class NoSearchResults(override val searchQuery: String) : SettingsSearchState(searchQuery) +} diff --git a/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml b/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml @@ -572,6 +572,14 @@ app:argType="string" app:nullable="true" /> <action + android:id="@+id/action_settingsFragment_to_settingsSearchFragment" + app:destination="@id/settingsSearchFragment" + app:enterAnim="@anim/slide_in_right" + app:exitAnim="@anim/slide_out_left" + app:popEnterAnim="@anim/slide_in_left" + app:popExitAnim="@anim/slide_out_right" + app:popUpTo="@id/settingsFragment" /> + <action android:id="@+id/action_settingsFragment_to_dataChoicesFragment" app:destination="@id/dataChoicesFragment" app:enterAnim="@anim/slide_in_right" @@ -799,6 +807,10 @@ android:name="org.mozilla.fenix.perf.ProfilerStopDialogFragment"> </dialog> <fragment + android:id="@+id/settingsSearchFragment" + android:name="org.mozilla.fenix.settings.settingssearch.SettingsSearchFragment" + android:label="@string/settings_search_title" /> + <fragment android:id="@+id/tabsSettingsFragment" android:name="org.mozilla.fenix.settings.TabsSettingsFragment" android:label="@string/preferences_tabs" /> diff --git a/mobile/android/fenix/app/src/main/res/values/static_strings.xml b/mobile/android/fenix/app/src/main/res/values/static_strings.xml @@ -262,4 +262,16 @@ <string name="crash_debug_show_startup_crash_screen">Show startup crash screen</string> <!-- Subtitle warning text for crash button --> <string name="crash_debug_startup_crash_warning">This will start the startup crash activity in a new process and terminate the current process.</string> + + <!-- Settings Search Strings --> + <!-- Title of Settings Search --> + <string name="settings_search_title">Settings Search</string> + <!-- Content description (not visible, for screen readers etc.): "Clear search field button for settings search" --> + <string name="content_description_settings_search_clear_search">Clear Search</string> + <!-- Content description (not visible, for screen readers etc.): "Navigate back button for settings search" --> + <string name="content_description_settings_search_navigate_back">Navigate Back</string> + <!-- Message displayed when the search field in the Settings Search screen is empty --> + <string name="settings_search_empty_query_placeholder">Search for settings</string> + <!-- Message when error happens with Settings Search --> + <string name="settings_search_error_message">Error occurred.</string> </resources> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddlewareTest.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SettingsSearchMiddlewareTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private var context: Context = mock() + + private fun buildMiddleware(): SettingsSearchMiddleware { + return SettingsSearchMiddleware( + SettingsSearchMiddleware.Companion.Dependencies( + context, + ), + ) + } + + @Test + fun `WHEN the settings search query is updated and results are not found THEN the state is updated`() { + val middleware = buildMiddleware() + val capture = CaptureActionsMiddleware<SettingsSearchState, SettingsSearchAction>() + val query = "test" + val store = SettingsSearchStore( + middleware = listOf( + middleware, + capture, + ), + ) + + store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) + store.waitUntilIdle() + + assert(store.state is SettingsSearchState.SearchInProgress) + assert(store.state.searchQuery == query) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStoreTest.kt @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.settingssearch + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SettingsSearchStoreTest { + + @Test + fun `GIVEN default state WHEN SearchQueryUpdated action is dispatched THEN query in state is updated`() { + val query = "theme" + val store = SettingsSearchStore() + + val initialState = SettingsSearchState.Default + assert(store.state == initialState) + + store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) + store.waitUntilIdle() + + assert(store.state is SettingsSearchState.SearchInProgress) + assert(store.state.searchQuery == query) + } + + @Test + fun `GIVEN search in progress state WHEN SearchQueryUpdated action is dispatched with empty query THEN default state is dispatched`() { + val store = SettingsSearchStore( + initialState = SettingsSearchState.SearchInProgress("theme", emptyList()), + ) + assert(store.state is SettingsSearchState.SearchInProgress) + + store.dispatch(SettingsSearchAction.SearchQueryUpdated("")) + store.waitUntilIdle() + + assert(store.state == SettingsSearchState.Default) + } +}