Close #18862: Add multi-select banner to tabs tray (#18932)

* Issue #18862: Add new addBookmark BookmarksUseCase

* Issue #18862: Add class for state binding features

* Issue #18862: Add delete multiple tabs to tray interactor

* Issue #18862: Add new actions to navigation interactor

* Issue #18862: Enable select mode from main tray menu

* Issue #18862: Add menu when in select mode

* Close #18862: Add multi-select banner to tabs tray

* Close #18862: Add select support for handle UI

We apply various layout changes to the "handle" UI in the tabs tray when
switching modes. It isn't quite clear to my, why we do this, if it's
really needed to meet the end result, and if there is a better way.

For now, we're simplying moving over that logic that we can re-evaluate
at a later time.
This commit is contained in:
Jonathan Almeida 2021-04-12 22:57:01 +04:00 committed by GitHub
parent 52209673fb
commit f3df2c73d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 824 additions and 56 deletions

View File

@ -0,0 +1,43 @@
/* 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.components
import androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
/**
* Helper class for creating small binding classes that are responsible for reacting to state
* changes.
*
* Taken with from Focus.
*/
abstract class AbstractBinding<in S : State>(
private val store: Store<S, out Action>
) : LifecycleAwareFeature {
private var scope: CoroutineScope? = null
@OptIn(ExperimentalCoroutinesApi::class)
@CallSuper
override fun start() {
scope = store.flowScoped { flow ->
onState(flow)
}
}
@CallSuper
override fun stop() {
scope?.cancel()
}
abstract suspend fun onState(flow: Flow<S>)
}

View File

@ -70,7 +70,8 @@ class Components(private val context: Context) {
core.sessionManager,
core.store,
core.webAppShortcutManager,
core.topSitesStorage
core.topSitesStorage,
core.bookmarksStorage
)
}

View File

@ -8,6 +8,7 @@ import android.content.Context
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.storage.BookmarksStorage
import mozilla.components.feature.app.links.AppLinksUseCases
import mozilla.components.feature.contextmenu.ContextMenuUseCases
import mozilla.components.feature.downloads.DownloadsUseCases
@ -23,6 +24,7 @@ import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSitesStorage
import mozilla.components.feature.top.sites.TopSitesUseCases
import mozilla.components.support.locale.LocaleUseCases
import org.mozilla.fenix.components.bookmarks.BookmarksUseCase
import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.fenix.utils.Mockable
@ -38,7 +40,8 @@ class UseCases(
private val sessionManager: SessionManager,
private val store: BrowserStore,
private val shortcutManager: WebAppShortcutManager,
private val topSitesStorage: TopSitesStorage
private val topSitesStorage: TopSitesStorage,
private val bookmarksStorage: BookmarksStorage
) {
/**
* Use cases that provide engine interactions for a given browser session.
@ -94,4 +97,9 @@ class UseCases(
* Use cases that handle locale management.
*/
val localeUseCases by lazyMonitored { LocaleUseCases(store) }
/**
* Use cases that provide bookmark management.
*/
val bookmarksUseCases by lazyMonitored { BookmarksUseCase(bookmarksStorage) }
}

View File

@ -0,0 +1,42 @@
/* 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.components.bookmarks
import androidx.annotation.WorkerThread
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarksStorage
/**
* Use cases that allow for modifying bookmarks.
*/
class BookmarksUseCase(storage: BookmarksStorage) {
class AddBookmarksUseCase internal constructor(private val storage: BookmarksStorage) {
/**
* Adds a new bookmark with the provided [url] and [title].
*
* @return The result if the operation was executed or not. A bookmark may not be added if
* one with the identical [url] already exists.
*/
@WorkerThread
suspend operator fun invoke(url: String, title: String, position: Int? = null): Boolean {
val canAdd = storage.getBookmarksWithUrl(url).firstOrNull { it.url == it.url } == null
if (canAdd) {
storage.addItem(
BookmarkRoot.Mobile.id,
url = url,
title = title,
position = position
)
}
return canAdd
}
}
val addBookmark by lazy { AddBookmarksUseCase(storage) }
}

View File

@ -5,15 +5,11 @@
package org.mozilla.fenix.tabstray
import android.content.Context
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import com.google.android.material.tabs.TabLayout
import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.R
import org.mozilla.fenix.utils.Do
/**
* A wrapper class that building the tabs tray menu that handles item clicks.
@ -43,31 +39,19 @@ class MenuIntegration(
fun build() = tabsTrayItemMenu.menuBuilder.build(context)
@VisibleForTesting
internal fun handleMenuClicked(item: TabsTrayMenu.Item) = when (item) {
is TabsTrayMenu.Item.ShareAllTabs ->
navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode)
is TabsTrayMenu.Item.OpenTabSettings ->
navigationInteractor.onTabSettingsClicked()
is TabsTrayMenu.Item.CloseAllTabs ->
navigationInteractor.onCloseAllTabsClicked(isPrivateMode)
is TabsTrayMenu.Item.OpenRecentlyClosed ->
navigationInteractor.onOpenRecentlyClosedClicked()
is TabsTrayMenu.Item.SelectTabs -> {
/* TODO implement when mulitiselect call is available */
internal fun handleMenuClicked(item: TabsTrayMenu.Item) {
Do exhaustive when (item) {
is TabsTrayMenu.Item.ShareAllTabs ->
navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode)
is TabsTrayMenu.Item.OpenTabSettings ->
navigationInteractor.onTabSettingsClicked()
is TabsTrayMenu.Item.CloseAllTabs ->
navigationInteractor.onCloseAllTabsClicked(isPrivateMode)
is TabsTrayMenu.Item.OpenRecentlyClosed ->
navigationInteractor.onOpenRecentlyClosedClicked()
is TabsTrayMenu.Item.SelectTabs -> {
tabsTrayStore.dispatch(TabsTrayAction.EnterSelectMode)
}
}
}
}
/**
* Invokes [BrowserMenu.show] and applies the default theme color background.
*/
fun BrowserMenu.showWithTheme(view: View) {
show(view).also { popupMenu ->
(popupMenu.contentView as? CardView)?.setCardBackgroundColor(
ContextCompat.getColor(
view.context,
R.color.foundation_normal_theme
)
)
}
}

View File

@ -5,10 +5,15 @@
package org.mozilla.fenix.tabstray
import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.tabstray.Tab
import org.mozilla.fenix.components.bookmarks.BookmarksUseCase
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.home.HomeFragment
@ -24,35 +29,53 @@ interface NavigationInteractor {
fun onTabTrayDismissed()
/**
* Called when user clicks the share tabs button.
* Called when sharing a list of [Tab]s.
*/
fun onShareTabs(tabs: Collection<Tab>)
/**
* Called when clicking the share tabs button.
*/
fun onShareTabsOfTypeClicked(private: Boolean)
/**
* Called when user clicks the tab settings button.
* Called when clicking the tab settings button.
*/
fun onTabSettingsClicked()
/**
* Called when user clicks the close all tabs button.
* Called when clicking the close all tabs button.
*/
fun onCloseAllTabsClicked(private: Boolean)
/**
* Called when user clicks the recently closed tabs menu button.
* Called when opening the recently closed tabs menu button.
*/
fun onOpenRecentlyClosedClicked()
/**
* Used when opening the add-to-collections user flow.
*/
fun onSaveToCollections(tabs: Collection<Tab>)
/**
* Used when adding [Tab]s as bookmarks.
*/
fun onSaveToBookmarks(tabs: Collection<Tab>)
}
/**
* A default implementation of [NavigationInteractor].
*/
@Suppress("LongParameterList")
class DefaultNavigationInteractor(
private val tabsTrayStore: TabsTrayStore,
private val browserStore: BrowserStore,
private val navController: NavController,
private val metrics: MetricController,
private val dismissTabTray: () -> Unit,
private val dismissTabTrayAndNavigateHome: (String) -> Unit
private val dismissTabTrayAndNavigateHome: (String) -> Unit,
private val bookmarksUseCase: BookmarksUseCase
) : NavigationInteractor {
override fun onTabTrayDismissed() {
@ -68,6 +91,16 @@ class DefaultNavigationInteractor(
metrics.track(Event.RecentlyClosedTabsOpened)
}
override fun onShareTabs(tabs: Collection<Tab>) {
val data = tabs.map {
ShareData(url = it.url, title = it.title)
}
val directions = TabsTrayFragmentDirections.actionGlobalShareFragment(
data = data.toTypedArray()
)
navController.navigate(directions)
}
override fun onShareTabsOfTypeClicked(private: Boolean) {
val tabs = browserStore.state.getNormalOrPrivateTabs(private)
val data = tabs.map {
@ -89,4 +122,24 @@ class DefaultNavigationInteractor(
dismissTabTrayAndNavigateHome(sessionsToClose)
}
override fun onSaveToCollections(tabs: Collection<Tab>) {
metrics.track(Event.TabsTraySaveToCollectionPressed)
// TODO add this is a separate PR; it's quite a large change.
}
override fun onSaveToBookmarks(tabs: Collection<Tab>) {
tabs.forEach { tab ->
// We don't combine the context with lifecycleScope so that our jobs are not cancelled
// if we leave the fragment, i.e. we still want the bookmarks to be added.
CoroutineScope(Dispatchers.IO).launch {
bookmarksUseCase.addBookmark(tab.url, tab.title)
}
}
tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode)
// TODO show successful snackbar here (regardless of operation succes).
}
}

View File

@ -15,13 +15,18 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import kotlinx.android.synthetic.main.component_tabstray.view.*
import kotlinx.android.synthetic.main.component_tabstray2.*
import kotlinx.android.synthetic.main.component_tabstray2.view.*
import kotlinx.android.synthetic.main.component_tabstray2.view.tab_tray_overflow
import kotlinx.android.synthetic.main.component_tabstray2.view.tab_wrapper
import kotlinx.android.synthetic.main.component_tabstray_fab.*
import kotlinx.android.synthetic.main.tabs_tray_tab_counter2.*
import kotlinx.android.synthetic.main.tabstray_multiselect_items.*
import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.plus
import mozilla.components.concept.tabstray.Tab
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.NavGraphDirections
@ -32,8 +37,13 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.home.HomeScreenViewModel
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.DefaultBrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.SelectionHandleBinding
import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding
import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding.VisibilityModifier
import org.mozilla.fenix.tabstray.ext.showWithTheme
import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor
@Suppress("TooManyFunctions")
class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
private var fabView: View? = null
@ -45,6 +55,8 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
private val tabLayoutMediator = ViewBoundFeatureWrapper<TabLayoutMediator>()
private val tabCounterBinding = ViewBoundFeatureWrapper<TabCounterBinding>()
private val floatingActionButtonBinding = ViewBoundFeatureWrapper<FloatingActionButtonBinding>()
private val selectionBannerBinding = ViewBoundFeatureWrapper<SelectionBannerBinding>()
private val selectionHandleBinding = ViewBoundFeatureWrapper<SelectionHandleBinding>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -73,7 +85,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
return containerView
}
@ExperimentalCoroutinesApi
@Suppress("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val activity = activity as HomeActivity
@ -100,11 +112,13 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
val navigationInteractor =
DefaultNavigationInteractor(
tabsTrayStore = tabsTrayStore,
browserStore = requireComponents.core.store,
navController = findNavController(),
metrics = requireComponents.analytics.metrics,
dismissTabTray = ::dismissAllowingStateLoss,
dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome
dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome,
bookmarksUseCase = requireComponents.useCases.bookmarksUseCases
)
val syncedTabsTrayInteractor = SyncedTabsInteractor(
@ -152,6 +166,41 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
owner = this,
view = view
)
selectionBannerBinding.set(
feature = SelectionBannerBinding(
context = requireContext(),
store = tabsTrayStore,
navInteractor = navigationInteractor,
tabsTrayInteractor = this,
containerView = view,
backgroundView = topBar,
showOnSelectViews = VisibilityModifier(
collect_multi_select,
share_multi_select,
menu_multi_select,
multiselect_title,
exit_multi_select
),
showOnNormalViews = VisibilityModifier(
tab_layout,
tab_tray_overflow,
new_tab_button
)
),
owner = this,
view = view
)
selectionHandleBinding.set(
feature = SelectionHandleBinding(
store = tabsTrayStore,
handle = handle,
containerLayout = tab_wrapper
),
owner = this,
view = view
)
}
override fun setCurrentTrayPosition(position: Int, smoothScroll: Boolean) {
@ -172,7 +221,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
}
}
override fun tabRemoved(tabId: String) {
override fun onDeleteTab(tabId: String) {
// TODO re-implement these methods
// showUndoSnackbarForTab(sessionId)
// removeIfNotLastTab(sessionId)
@ -181,6 +230,12 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor {
requireComponents.useCases.tabsUseCases.removeTab(tabId)
}
override fun onDeleteTabs(tabs: Collection<Tab>) {
tabs.forEach {
onDeleteTab(it.id)
}
}
private fun setupPager(
context: Context,
store: TabsTrayStore,

View File

@ -4,6 +4,8 @@
package org.mozilla.fenix.tabstray
import mozilla.components.concept.tabstray.Tab
interface TabsTrayInteractor {
/**
* Set the current tray item to the clamped [position].
@ -21,5 +23,10 @@ interface TabsTrayInteractor {
/**
* Invoked when a tab is removed from the tabs tray with the given [tabId].
*/
fun tabRemoved(tabId: String)
fun onDeleteTab(tabId: String)
/**
* Invoked when [Tab]s need to be deleted.
*/
fun onDeleteTabs(tabs: Collection<Tab>)
}

View File

@ -48,7 +48,7 @@ abstract class BaseBrowserTrayList @JvmOverloads constructor(
val removeTabUseCase = RemoveTabUseCaseWrapper(
context.components.analytics.metrics
) { sessionId ->
interactor.tabRemoved(sessionId)
interactor.onDeleteTab(sessionId)
}
TabsFeature(

View File

@ -64,7 +64,7 @@ class DefaultBrowserTrayInteractor(
private val removeTabWrapper by lazy {
RemoveTabUseCaseWrapper(metrics) {
// Handle removal from the interactor where we can also handle "undo" visuals.
trayInteractor.tabRemoved(it)
trayInteractor.onDeleteTab(it)
}
}

View File

@ -0,0 +1,141 @@
/* 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.tabstray.browser
import android.content.Context
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.component_tabstray2.view.exit_multi_select
import kotlinx.android.synthetic.main.component_tabstray2.view.multiselect_title
import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R
import org.mozilla.fenix.components.AbstractBinding
import org.mozilla.fenix.tabstray.NavigationInteractor
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TabsTrayAction.ExitSelectMode
import org.mozilla.fenix.tabstray.TabsTrayState.Mode
import org.mozilla.fenix.tabstray.TabsTrayState.Mode.Select
import org.mozilla.fenix.tabstray.ext.showWithTheme
/**
* A binding that shows/hides the multi-select banner of the selected count of tabs.
*
* @property context An Android context.
* @property store The TabsTrayStore instance.
* @property navInteractor An instance of [NavigationInteractor] for navigating on menu clicks.
* @property tabsTrayInteractor An instance of [TabsTrayInteractor] for handling deletion.
* @property containerView The view in the layout that contains all the implicit multi-select
* views. NB: This parameter is a bit opaque and requires a larger layout refactor to correct.
* @property backgroundView The background view that we want to alter when changing [Mode].
* @property showOnSelectViews A variable list of views that will be made visible when in select mode.
* @property showOnNormalViews A variable list of views that will be made visible when in normal mode.
*/
@Suppress("LongParameterList")
class SelectionBannerBinding(
private val context: Context,
private val store: TabsTrayStore,
private val navInteractor: NavigationInteractor,
private val tabsTrayInteractor: TabsTrayInteractor,
private val containerView: View,
private val backgroundView: View,
private val showOnSelectViews: VisibilityModifier,
private val showOnNormalViews: VisibilityModifier
) : AbstractBinding<TabsTrayState>(store) {
/**
* A holder of views that will be used by having their [View.setVisibility] modified.
*/
class VisibilityModifier(vararg val views: View)
private var isPreviousModeSelect = false
override fun start() {
super.start()
initListeners(containerView)
}
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.mode }
// ignore initial mode update; we never start in select mode.
.drop(1)
.ifChanged()
.collect { mode ->
val isSelectMode = mode is Select
showOnSelectViews.views.forEach {
it.isVisible = isSelectMode
}
showOnNormalViews.views.forEach {
it.isVisible = isSelectMode.not()
}
updateBackgroundColor(isSelectMode)
updateSelectTitle(isSelectMode, mode.selectedTabs.size)
isPreviousModeSelect = isSelectMode
}
}
private fun initListeners(containerView: View) {
containerView.share_multi_select.setOnClickListener {
navInteractor.onShareTabs(store.state.mode.selectedTabs)
}
containerView.collect_multi_select.setOnClickListener {
navInteractor.onSaveToCollections(store.state.mode.selectedTabs)
}
containerView.exit_multi_select.setOnClickListener {
store.dispatch(ExitSelectMode)
}
containerView.menu_multi_select.setOnClickListener { anchor ->
val menu = SelectionMenuIntegration(
context,
store,
navInteractor,
tabsTrayInteractor
).build()
menu.showWithTheme(anchor)
}
}
@VisibleForTesting
private fun updateBackgroundColor(isSelectMode: Boolean) {
// memoize to avoid setting the background unnecessarily.
if (isPreviousModeSelect != isSelectMode) {
val colorResource = if (isSelectMode) {
R.color.accent_normal_theme
} else {
R.color.foundation_normal_theme
}
val color = ContextCompat.getColor(backgroundView.context, colorResource)
backgroundView.setBackgroundColor(color)
}
}
@VisibleForTesting
private fun updateSelectTitle(selectedMode: Boolean, tabCount: Int) {
if (selectedMode) {
containerView.multiselect_title.text =
context.getString(R.string.tab_tray_multi_select_title, tabCount)
}
}
}

View File

@ -0,0 +1,108 @@
/* 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.tabstray.browser
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R
import org.mozilla.fenix.components.AbstractBinding
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayState.Mode
import org.mozilla.fenix.tabstray.TabsTrayStore
private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F
/**
* Various layout updates that need to be applied to the "handle" view when switching
* between [Mode].
*
* @param store The TabsTrayStore instance.
* @property handle The "handle" of the Tabs Tray that is used to drag the tray open/close.
* @property containerLayout The [ConstraintLayout] that contains the "handle".
*/
class SelectionHandleBinding(
store: TabsTrayStore,
private val handle: View,
private val containerLayout: ConstraintLayout
) : AbstractBinding<TabsTrayState>(store) {
private var isPreviousModeSelect = false
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.mode }
// ignore initial mode update; we never start in select mode.
.drop(1)
.ifChanged()
.collect { mode ->
val isSelectMode = mode is Mode.Select
// memoize to avoid unnecessary layout updates.
if (isPreviousModeSelect != isSelectMode) {
updateLayoutParams(handle, isSelectMode)
updateBackgroundColor(handle, isSelectMode)
updateWidthPercent(containerLayout, handle, isSelectMode)
}
isPreviousModeSelect = isSelectMode
}
}
private fun updateLayoutParams(handle: View, multiselect: Boolean) {
handle.updateLayoutParams<ViewGroup.MarginLayoutParams> {
height = handle.resources.getDimensionPixelSize(
if (multiselect) {
R.dimen.tab_tray_multiselect_handle_height
} else {
R.dimen.bottom_sheet_handle_height
}
)
topMargin = handle.resources.getDimensionPixelSize(
if (multiselect) {
R.dimen.tab_tray_multiselect_handle_top_margin
} else {
R.dimen.bottom_sheet_handle_top_margin
}
)
}
}
private fun updateBackgroundColor(handle: View, multiselect: Boolean) {
val colorResource = if (multiselect) {
R.color.accent_normal_theme
} else {
R.color.secondary_text_normal_theme
}
val color = ContextCompat.getColor(handle.context, colorResource)
handle.setBackgroundColor(color)
}
private fun updateWidthPercent(
container: ConstraintLayout,
handle: View,
multiselect: Boolean
) {
val widthPercent = if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH
container.run {
ConstraintSet().apply {
clone(this@run)
constrainPercentWidth(handle.id, widthPercent)
applyTo(this@run)
}
}
}
}

View File

@ -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.tabstray.browser
import android.content.Context
import mozilla.components.browser.menu.BrowserMenuBuilder
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import org.mozilla.fenix.R
class SelectionMenu(
private val context: Context,
private val onItemTapped: (Item) -> Unit = {}
) {
sealed class Item {
object BookmarkTabs : Item()
object DeleteTabs : Item()
}
val menuBuilder by lazy { BrowserMenuBuilder(menuItems) }
private val menuItems by lazy {
listOf(
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_multiselect_menu_item_bookmark),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.BookmarkTabs)
},
SimpleBrowserMenuItem(
context.getString(R.string.tab_tray_multiselect_menu_item_close),
textColorResource = R.color.primary_text_normal_theme
) {
onItemTapped.invoke(Item.DeleteTabs)
}
)
}
}

View File

@ -0,0 +1,41 @@
/* 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.tabstray.browser
import android.content.Context
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.menu.BrowserMenuBuilder
import org.mozilla.fenix.tabstray.NavigationInteractor
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.utils.Do
class SelectionMenuIntegration(
private val context: Context,
private val store: TabsTrayStore,
private val navInteractor: NavigationInteractor,
private val trayInteractor: TabsTrayInteractor
) {
private val menu by lazy {
SelectionMenu(context, ::handleMenuClicked)
}
/**
* Builds the internal menu items list. See [BrowserMenuBuilder.build].
*/
fun build() = menu.menuBuilder.build(context)
@VisibleForTesting
internal fun handleMenuClicked(item: SelectionMenu.Item) {
Do exhaustive when (item) {
is SelectionMenu.Item.BookmarkTabs -> navInteractor.onSaveToBookmarks(
store.state.mode.selectedTabs
)
is SelectionMenu.Item.DeleteTabs -> trayInteractor.onDeleteTabs(
store.state.mode.selectedTabs
)
}
}
}

View File

@ -0,0 +1,25 @@
/* 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.tabstray.ext
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import mozilla.components.browser.menu.BrowserMenu
import org.mozilla.fenix.R
/**
* Invokes [BrowserMenu.show] and applies the default theme color background.
*/
fun BrowserMenu.showWithTheme(view: View) {
show(view).also { popupMenu ->
(popupMenu.contentView as? CardView)?.setCardBackgroundColor(
ContextCompat.getColor(
view.context,
R.color.foundation_normal_theme
)
)
}
}

View File

@ -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.components
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.test.TestCoroutineDispatcher
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AbstractBindingTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher())
@Test
fun `WHEN started THEN onState flow is invoked`() {
val store = BrowserStore()
var invoked = false
val binding = TestBinding(store) {
invoked = true
}
binding.start()
store.waitUntilIdle()
assertTrue(invoked)
}
@Test
fun `WHEN actions are dispatched THEN onState flow is invoked`() {
val store = BrowserStore()
var invoked = false
val binding = TestBinding(store) {
if (store.state.tabs.isNotEmpty()) {
invoked = true
}
}
binding.start()
store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
store.waitUntilIdle()
assertTrue(invoked)
}
class TestBinding(
store: BrowserStore,
private val invoked: (BrowserState) -> Unit
) : AbstractBinding<BrowserState>(store) {
override suspend fun onState(flow: Flow<BrowserState>) {
flow.collect {
invoked(it)
}
}
}
}

View File

@ -24,7 +24,8 @@ class TestComponents(private val context: Context) : Components(context) {
core.sessionManager,
core.store,
core.webAppShortcutManager,
core.topSitesStorage
core.topSitesStorage,
core.bookmarksStorage
)
}
override val intentProcessors by lazy { mockk<IntentProcessors>(relaxed = true) }

View File

@ -9,6 +9,7 @@ import io.mockk.every
import io.mockk.mockk
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.concept.engine.Engine
@ -32,4 +33,5 @@ class TestCore(context: Context, crashReporter: CrashReporting) : Core(
override val webAppShortcutManager = mockk<WebAppShortcutManager>()
override val thumbnailStorage = mockk<ThumbnailStorage>()
override val topSitesStorage = mockk<DefaultTopSitesStorage>()
override val bookmarksStorage = mockk<PlacesBookmarksStorage>()
}

View File

@ -0,0 +1,50 @@
/* 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.components.bookmarks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarksStorage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ExperimentalCoroutinesApi
class BookmarksUseCaseTest {
@Test
fun `WHEN adding existing bookmark THEN no new item is stored`() = runBlockingTest {
val storage = mockk<BookmarksStorage>()
val bookmarkNode = mockk<BookmarkNode>()
val useCase = BookmarksUseCase(storage)
every { bookmarkNode.url }.answers { "https://mozilla.org" }
coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) }
val result = useCase.addBookmark("https://mozilla.org", "Mozilla")
assertFalse(result)
}
@Test
fun `WHEN adding bookmark THEN new item is stored`() = runBlockingTest {
val storage = mockk<BookmarksStorage>(relaxed = true)
val useCase = BookmarksUseCase(storage)
coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { emptyList() }
val result = useCase.addBookmark("https://mozilla.org", "Mozilla")
assertTrue(result)
coVerify { storage.addItem(BookmarkRoot.Mobile.id, "https://mozilla.org", "Mozilla", null) }
}
}

View File

@ -6,13 +6,21 @@ package org.mozilla.fenix.tabstray
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.middleware.CaptureActionsMiddleware
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertNotNull
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
class MenuIntegrationTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher())
private val captureMiddleware = CaptureActionsMiddleware<TabsTrayState, TabsTrayAction>()
private val tabsTrayStore = TabsTrayStore(middlewares = listOf(captureMiddleware))
private val interactor = mockk<NavigationInteractor>(relaxed = true)
@ -53,12 +61,13 @@ class MenuIntegrationTest {
verify { interactor.onOpenRecentlyClosedClicked() }
}
@Ignore("Enable after we connect this menu item to the store")
@Test
fun `WHEN the select menu item is clicked THEN invoke the action`() {
val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor)
menu.handleMenuClicked(TabsTrayMenu.Item.ShareAllTabs)
menu.handleMenuClicked(TabsTrayMenu.Item.SelectTabs)
tabsTrayStore.waitUntilIdle()
assertNotNull(captureMiddleware.findLastAction(TabsTrayAction.EnterSelectMode::class))
}

View File

@ -6,34 +6,45 @@ package org.mozilla.fenix.tabstray
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.state.createTab as createStateTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.tabstray.Tab
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.components.bookmarks.BookmarksUseCase
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.tabstray.browser.createTab as createTrayTab
class NavigationInteractorTest {
private lateinit var store: BrowserStore
private lateinit var tabsTrayStore: TabsTrayStore
private lateinit var navigationInteractor: NavigationInteractor
private val testTab: TabSessionState = createTab(url = "https://mozilla.org")
private val testTab: TabSessionState = createStateTab(url = "https://mozilla.org")
private val navController: NavController = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val dismissTabTray: () -> Unit = mockk(relaxed = true)
private val dismissTabTrayAndNavigateHome: (String) -> Unit = mockk(relaxed = true)
private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true)
@Before
fun setup() {
store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab)))
tabsTrayStore = TabsTrayStore()
navigationInteractor = DefaultNavigationInteractor(
tabsTrayStore,
store,
navController,
metrics,
dismissTabTray,
dismissTabTrayAndNavigateHome
dismissTabTrayAndNavigateHome,
bookmarksUseCase
)
}
@ -44,6 +55,9 @@ class NavigationInteractorTest {
var openRecentlyClosedClicked = false
var shareTabsOfTypeClicked = false
var closeAllTabsClicked = false
var onShareTabs = false
var onSaveToCollections = false
var onBookmarkTabs = false
class TestNavigationInteractor : NavigationInteractor {
@ -51,6 +65,10 @@ class NavigationInteractorTest {
tabTrayDismissed = true
}
override fun onShareTabs(tabs: Collection<Tab>) {
onShareTabs = true
}
override fun onTabSettingsClicked() {
tabSettingsClicked = true
}
@ -59,6 +77,14 @@ class NavigationInteractorTest {
openRecentlyClosedClicked = true
}
override fun onSaveToCollections(tabs: Collection<Tab>) {
onSaveToCollections = true
}
override fun onSaveToBookmarks(tabs: Collection<Tab>) {
onBookmarkTabs = true
}
override fun onShareTabsOfTypeClicked(private: Boolean) {
shareTabsOfTypeClicked = true
}
@ -70,15 +96,21 @@ class NavigationInteractorTest {
val navigationInteractor: NavigationInteractor = TestNavigationInteractor()
navigationInteractor.onTabTrayDismissed()
assert(tabTrayDismissed)
assertTrue(tabTrayDismissed)
navigationInteractor.onTabSettingsClicked()
assert(tabSettingsClicked)
assertTrue(tabSettingsClicked)
navigationInteractor.onOpenRecentlyClosedClicked()
assert(openRecentlyClosedClicked)
assertTrue(openRecentlyClosedClicked)
navigationInteractor.onShareTabsOfTypeClicked(true)
assert(shareTabsOfTypeClicked)
assertTrue(shareTabsOfTypeClicked)
navigationInteractor.onCloseAllTabsClicked(true)
assert(closeAllTabsClicked)
assertTrue(closeAllTabsClicked)
navigationInteractor.onShareTabs(emptyList())
assertTrue(onShareTabs)
navigationInteractor.onSaveToCollections(emptyList())
assertTrue(onSaveToCollections)
navigationInteractor.onSaveToBookmarks(emptyList())
assertTrue(onBookmarkTabs)
}
@Test
@ -110,4 +142,22 @@ class NavigationInteractorTest {
navigationInteractor.onShareTabsOfTypeClicked(false)
verify(exactly = 1) { navController.navigate(any<NavDirections>()) }
}
@Test
fun `onShareTabs calls navigation on DefaultNavigationInteractor`() {
navigationInteractor.onShareTabs(emptyList())
verify(exactly = 1) { navController.navigate(any<NavDirections>()) }
}
@Test
fun `onSaveToCollections calls navigation on DefaultNavigationInteractor`() {
navigationInteractor.onSaveToCollections(emptyList())
verify(exactly = 1) { metrics.track(Event.TabsTraySaveToCollectionPressed) }
}
@Test
fun `onBookmarkTabs calls navigation on DefaultNavigationInteractor`() {
navigationInteractor.onSaveToBookmarks(listOf(createTrayTab()))
coVerify(exactly = 1) { bookmarksUseCase.addBookmark(any(), any(), any()) }
}
}

View File

@ -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.tabstray.browser
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.mozilla.fenix.tabstray.NavigationInteractor
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
class SelectionMenuIntegrationTest {
private val navInteractor = mockk<NavigationInteractor>(relaxed = true)
private val trayInteractor = mockk<TabsTrayInteractor>(relaxed = true)
private val store = TabsTrayStore()
@Test
fun `WHEN bookmark item is clicked THEN invoke interactor`() {
val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor)
menu.handleMenuClicked(SelectionMenu.Item.BookmarkTabs)
verify { navInteractor.onSaveToBookmarks(store.state.mode.selectedTabs) }
}
@Test
fun `WHEN delete tabs item is clicked THEN invoke interactor`() {
val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor)
menu.handleMenuClicked(SelectionMenu.Item.DeleteTabs)
verify { trayInteractor.onDeleteTabs(store.state.mode.selectedTabs) }
}
}