For #24297 - Add a search engine menu that shows the search engine and search settings
parent
dfe23e8b77
commit
8fe9c5bdd1
@ -0,0 +1,29 @@
|
|||||||
|
/* 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.search.toolbar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.RelativeLayout
|
||||||
|
import org.mozilla.fenix.databinding.SearchSelectorBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A search selector menu used in the Browser Toolbar in Edit mode.
|
||||||
|
*/
|
||||||
|
internal class SearchSelector @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyle: Int = 0
|
||||||
|
) : RelativeLayout(context, attrs, defStyle) {
|
||||||
|
|
||||||
|
private val binding = SearchSelectorBinding.inflate(LayoutInflater.from(context), this)
|
||||||
|
|
||||||
|
fun setIcon(icon: Drawable, contentDescription: String) {
|
||||||
|
binding.icon.setImageDrawable(icon)
|
||||||
|
binding.icon.contentDescription = contentDescription
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
/* 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.search.toolbar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import mozilla.components.browser.menu2.BrowserMenuController
|
||||||
|
import mozilla.components.browser.state.search.SearchEngine
|
||||||
|
import mozilla.components.concept.menu.MenuController
|
||||||
|
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
|
||||||
|
import mozilla.components.concept.menu.candidate.TextMenuCandidate
|
||||||
|
import mozilla.components.support.ktx.android.content.getColorFromAttr
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
|
||||||
|
typealias MozSearchEngine = SearchEngine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A popup menu composed of [SearchSelectorMenu.Item] objects.
|
||||||
|
*
|
||||||
|
* @property context [Context] used for various Android interactions.
|
||||||
|
* @property interactor [ToolbarInteractor] for handling menu item interactions.
|
||||||
|
*/
|
||||||
|
class SearchSelectorMenu(
|
||||||
|
private val context: Context,
|
||||||
|
private val interactor: ToolbarInteractor
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items that will appear in the search selector menu.
|
||||||
|
*/
|
||||||
|
sealed class Item {
|
||||||
|
/**
|
||||||
|
* The menu item to navigate to the search settings.
|
||||||
|
*/
|
||||||
|
object SearchSettings : Item()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu item to display a search engine.
|
||||||
|
*
|
||||||
|
* @param searchEngine The [SearchEngine] that was selected.
|
||||||
|
*/
|
||||||
|
data class SearchEngine(val searchEngine: MozSearchEngine) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
val menuController: MenuController by lazy { BrowserMenuController() }
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun menuItems(): List<TextMenuCandidate> {
|
||||||
|
return listOf(
|
||||||
|
TextMenuCandidate(
|
||||||
|
text = context.getString(R.string.search_settings_menu_item),
|
||||||
|
start = DrawableMenuIcon(
|
||||||
|
drawable = AppCompatResources.getDrawable(
|
||||||
|
context,
|
||||||
|
R.drawable.mozac_ic_settings
|
||||||
|
),
|
||||||
|
tint = context.getColorFromAttr(R.attr.textPrimary)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
interactor.onMenuItemTapped(Item.SearchSettings)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
/* 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.search.toolbar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import mozilla.components.browser.state.search.SearchEngine
|
||||||
|
import mozilla.components.concept.toolbar.Toolbar
|
||||||
|
import mozilla.components.lib.state.ext.flowScoped
|
||||||
|
import mozilla.components.support.ktx.android.content.res.resolveAttribute
|
||||||
|
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.search.SearchDialogFragmentStore
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Toolbar.Action] implementation that shows a [SearchSelector].
|
||||||
|
*
|
||||||
|
* @property store [SearchDialogFragmentStore] containing the complete state of the search dialog.
|
||||||
|
* @property menu An instance of [SearchSelectorMenu] to display a popup menu for the search
|
||||||
|
* selections.
|
||||||
|
* @property viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
|
||||||
|
*/
|
||||||
|
class SearchSelectorToolbarAction(
|
||||||
|
private val store: SearchDialogFragmentStore,
|
||||||
|
private val menu: SearchSelectorMenu,
|
||||||
|
private val viewLifecycleOwner: LifecycleOwner
|
||||||
|
) : Toolbar.Action {
|
||||||
|
|
||||||
|
private var reference = WeakReference<SearchSelector>(null)
|
||||||
|
|
||||||
|
override fun createView(parent: ViewGroup): View {
|
||||||
|
val context = parent.context
|
||||||
|
|
||||||
|
store.flowScoped(viewLifecycleOwner) { flow ->
|
||||||
|
flow.map { state -> state.searchEngineSource.searchEngine }
|
||||||
|
.ifChanged()
|
||||||
|
.collect { searchEngine ->
|
||||||
|
searchEngine?.let {
|
||||||
|
updateIcon(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchSelector(context).apply {
|
||||||
|
reference = WeakReference(this)
|
||||||
|
|
||||||
|
setOnClickListener {
|
||||||
|
menu.menuController.show(anchor = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackgroundResource(
|
||||||
|
context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(view: View) = Unit
|
||||||
|
|
||||||
|
private fun updateIcon(context: Context, searchEngine: SearchEngine) {
|
||||||
|
val iconSize =
|
||||||
|
context.resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
|
||||||
|
val scaledIcon = Bitmap.createScaledBitmap(
|
||||||
|
searchEngine.icon,
|
||||||
|
iconSize,
|
||||||
|
iconSize,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val icon = BitmapDrawable(context.resources, scaledIcon)
|
||||||
|
|
||||||
|
reference.get()?.setIcon(icon, searchEngine.name)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 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/. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="6dp"
|
||||||
|
android:height="6dp"
|
||||||
|
android:viewportWidth="6"
|
||||||
|
android:viewportHeight="6">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/textPrimary"
|
||||||
|
android:pathData="M0.8536,1.6465C0.6583,1.4512 0.3417,1.4512 0.1464,1.6465C-0.0488,1.8417 -0.0488,2.1583 0.1464,2.3535L0.8536,1.6465ZM3,4.5L2.6465,4.8535H3.3535L3,4.5ZM5.8535,2.3535C6.0488,2.1583 6.0488,1.8417 5.8535,1.6465C5.6583,1.4512 5.3417,1.4512 5.1465,1.6465L5.8535,2.3535ZM0.1464,2.3535L2.6465,4.8535L3.3535,4.1465L0.8536,1.6465L0.1464,2.3535ZM3.3535,4.8535L5.8535,2.3535L5.1465,1.6465L2.6465,4.1465L3.3535,4.8535Z" />
|
||||||
|
</vector>
|
@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 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/. -->
|
||||||
|
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
tools:layout_height="wrap_content"
|
||||||
|
tools:layout_width="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/search_selector"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
app:cardBackgroundColor="?attr/layer2"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/tab_item"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginVertical="2dp"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_marginEnd="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:scaleType="center"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
app:shapeAppearanceOverlay="@style/SearchSelectorIconStyle"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:src="@drawable/ic_search" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatImageView
|
||||||
|
android:id="@+id/arrow"
|
||||||
|
android:layout_width="6dp"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
app:srcCompat="@drawable/ic_chevron_down_6"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
</merge>
|
@ -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.search.toolbar
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import mozilla.components.support.test.robolectric.testContext
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.databinding.SearchSelectorBinding
|
||||||
|
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(FenixRobolectricTestRunner::class)
|
||||||
|
class SearchSelectorTest {
|
||||||
|
|
||||||
|
private lateinit var searchSelector: SearchSelector
|
||||||
|
private lateinit var binding: SearchSelectorBinding
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
searchSelector = SearchSelector(testContext)
|
||||||
|
binding = SearchSelectorBinding.inflate(LayoutInflater.from(testContext), searchSelector)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN set icon is called THEN an icon and its content description are set`() {
|
||||||
|
val icon = AppCompatResources.getDrawable(testContext, R.drawable.ic_search)!!
|
||||||
|
val contentDescription = "contentDescription"
|
||||||
|
|
||||||
|
searchSelector.setIcon(icon, contentDescription)
|
||||||
|
|
||||||
|
assertEquals(icon, binding.icon.drawable)
|
||||||
|
assertEquals(contentDescription, binding.icon.contentDescription)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
/* 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.search.toolbar
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import io.mockk.MockKAnnotations
|
||||||
|
import io.mockk.impl.annotations.MockK
|
||||||
|
import io.mockk.spyk
|
||||||
|
import io.mockk.verify
|
||||||
|
import mozilla.components.support.test.robolectric.testContext
|
||||||
|
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||||
|
import org.mozilla.fenix.search.SearchDialogFragmentStore
|
||||||
|
|
||||||
|
@RunWith(FenixRobolectricTestRunner::class)
|
||||||
|
class SearchSelectorToolbarActionTest {
|
||||||
|
|
||||||
|
@MockK(relaxed = true)
|
||||||
|
private lateinit var store: SearchDialogFragmentStore
|
||||||
|
|
||||||
|
@MockK(relaxed = true)
|
||||||
|
private lateinit var menu: SearchSelectorMenu
|
||||||
|
|
||||||
|
private lateinit var lifecycleOwner: MockedLifecycleOwner
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val coroutinesTestRule = MainCoroutineRule()
|
||||||
|
|
||||||
|
internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
|
||||||
|
val lifecycleRegistry = LifecycleRegistry(this).apply {
|
||||||
|
currentState = initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLifecycle(): Lifecycle = lifecycleRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
MockKAnnotations.init(this)
|
||||||
|
|
||||||
|
lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN search selector toolbar action is clicked THEN the search selector menu is shown`() {
|
||||||
|
val action = spyk(
|
||||||
|
SearchSelectorToolbarAction(
|
||||||
|
store = store,
|
||||||
|
menu = menu,
|
||||||
|
viewLifecycleOwner = lifecycleOwner
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val view = action.createView(LinearLayout(testContext) as ViewGroup) as SearchSelector
|
||||||
|
|
||||||
|
view.performClick()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
menu.menuController.show(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue