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