From a197281092b1392c6a4917126fdff6bc5f58ad45 Mon Sep 17 00:00:00 2001 From: ekager Date: Wed, 28 Oct 2020 12:41:52 -0700 Subject: [PATCH] [fenix] For https://github.com/mozilla-mobile/fenix/issues/16132 - Revise multiselect mode UI --- .../fenix/addons/AddonsManagementFragment.kt | 2 +- .../fenix/tabtray/MultiselectSelectionMenu.kt | 44 +++++++ .../fenix/tabtray/TabTrayController.kt | 95 +++++++++++--- .../fenix/tabtray/TabTrayDialogFragment.kt | 47 ++++++- .../tabtray/TabTrayFragmentInteractor.kt | 43 +++++-- .../mozilla/fenix/tabtray/TabTrayItemMenu.kt | 72 +++++++++++ .../org/mozilla/fenix/tabtray/TabTrayView.kt | 121 +++++++----------- .../main/res/layout/component_tabstray.xml | 41 ++---- .../tabs_tray_save_to_collections_item.xml | 11 +- .../res/layout/tabstray_multiselect_items.xml | 50 ++++++++ app/src/main/res/values/strings.xml | 16 +++ .../tabtray/DefaultTabTrayControllerTest.kt | 80 ++++++++++-- .../tabtray/TabTrayFragmentInteractorTest.kt | 49 +++++-- 13 files changed, 508 insertions(+), 163 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/MultiselectSelectionMenu.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/TabTrayItemMenu.kt create mode 100644 app/src/main/res/layout/tabstray_multiselect_items.xml diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt index 059ae67e0..670df4130 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -141,7 +141,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management) private fun hasExistingAddonInstallationDialogFragment(): Boolean { return parentFragmentManager.findFragmentByTag(INSTALLATION_DIALOG_FRAGMENT_TAG) - as? AddonInstallationDialogFragment != null + as? AddonInstallationDialogFragment != null } private fun showPermissionDialog(addon: Addon) { diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/MultiselectSelectionMenu.kt b/app/src/main/java/org/mozilla/fenix/tabtray/MultiselectSelectionMenu.kt new file mode 100644 index 000000000..c2c8b8c83 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/MultiselectSelectionMenu.kt @@ -0,0 +1,44 @@ +/* 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.tabtray + +import android.content.Context +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components + +class MultiselectSelectionMenu( + 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 + ) { + context.components.analytics.metrics.track(Event.TabsTraySaveToCollectionPressed) + onItemTapped.invoke(Item.BookmarkTabs) + }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_multiselect_menu_item_close), + textColorResource = R.color.primary_text_normal_theme + ) { + context.components.analytics.metrics.track(Event.TabsTrayShareAllTabsPressed) + onItemTapped.invoke(Item.DeleteTabs) + } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt index b871eba3b..201ef8573 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt @@ -4,13 +4,20 @@ package org.mozilla.fenix.tabtray -import androidx.annotation.VisibleForTesting import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.base.profiler.Profiler import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.concept.tabstray.Tab import mozilla.components.feature.tabs.TabsUseCases import org.mozilla.fenix.BrowserDirection @@ -18,7 +25,6 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.TabCollectionStorage -import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.home.HomeFragment import mozilla.components.browser.storage.sync.Tab as SyncTab @@ -29,13 +35,16 @@ import mozilla.components.browser.storage.sync.Tab as SyncTab */ @Suppress("TooManyFunctions") interface TabTrayController { - fun onNewTabTapped(private: Boolean) - fun onTabTrayDismissed() + fun handleNewTabTapped(private: Boolean) + fun handleTabTrayDismissed() fun handleTabSettingsClicked() - fun onShareTabsClicked(private: Boolean) - fun onSyncedTabClicked(syncTab: SyncTab) - fun onSaveToCollectionClicked(selectedTabs: Set) - fun onCloseAllTabsClicked(private: Boolean) + fun handleShareTabsOfTypeClicked(private: Boolean) + fun handleShareSelectedTabsClicked(selectedTabs: Set) + fun handleSyncedTabClicked(syncTab: SyncTab) + fun handleSaveToCollectionClicked(selectedTabs: Set) + fun handleBookmarkSelectedTabs(selectedTabs: Set) + fun handleDeleteSelectedTabs(selectedTabs: Set) + fun handleCloseAllTabsClicked(private: Boolean) fun handleBackPressed(): Boolean fun onModeRequested(): TabTrayDialogFragmentState.Mode fun handleAddSelectedTab(tab: Tab) @@ -68,8 +77,12 @@ class DefaultTabTrayController( private val activity: HomeActivity, private val profiler: Profiler?, private val sessionManager: SessionManager, + private val browserStore: BrowserStore, private val browsingModeManager: BrowsingModeManager, private val tabCollectionStorage: TabCollectionStorage, + private val bookmarksStorage: BookmarksStorage, + private val scope: CoroutineScope, + private val tabsUseCases: TabsUseCases, private val navController: NavController, private val dismissTabTray: () -> Unit, private val dismissTabTrayAndNavigateHome: (String) -> Unit, @@ -77,10 +90,12 @@ class DefaultTabTrayController( private val tabTrayDialogFragmentStore: TabTrayDialogFragmentStore, private val selectTabUseCase: TabsUseCases.SelectTabUseCase, private val showChooseCollectionDialog: (List) -> Unit, - private val showAddNewCollectionDialog: (List) -> Unit + private val showAddNewCollectionDialog: (List) -> Unit, + private val showUndoSnackbarForTabs: () -> Unit, + private val showBookmarksSnackbar: () -> Unit ) : TabTrayController { - override fun onNewTabTapped(private: Boolean) { + override fun handleNewTabTapped(private: Boolean) { val startTime = profiler?.getProfilerTime() browsingModeManager.mode = BrowsingMode.fromBoolean(private) navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) @@ -95,11 +110,11 @@ class DefaultTabTrayController( navController.navigate(TabTrayDialogFragmentDirections.actionGlobalTabSettingsFragment()) } - override fun onTabTrayDismissed() { + override fun handleTabTrayDismissed() { dismissTabTray() } - override fun onSaveToCollectionClicked(selectedTabs: Set) { + override fun handleSaveToCollectionClicked(selectedTabs: Set) { val sessionList = selectedTabs.map { sessionManager.findSessionById(it.id) ?: return } @@ -117,9 +132,19 @@ class DefaultTabTrayController( } } - override fun onShareTabsClicked(private: Boolean) { - val tabs = getListOfSessions(private) + override fun handleShareTabsOfTypeClicked(private: Boolean) { + val tabs = browserStore.state.getNormalOrPrivateTabs(private) val data = tabs.map { + ShareData(url = it.content.url, title = it.content.title) + } + val directions = TabTrayDialogFragmentDirections.actionGlobalShareFragment( + data = data.toTypedArray() + ) + navController.navigate(directions) + } + + override fun handleShareSelectedTabsClicked(selectedTabs: Set) { + val data = selectedTabs.map { ShareData(url = it.url, title = it.title) } val directions = TabTrayDialogFragmentDirections.actionGlobalShareFragment( @@ -128,7 +153,40 @@ class DefaultTabTrayController( navController.navigate(directions) } - override fun onSyncedTabClicked(syncTab: SyncTab) { + override fun handleBookmarkSelectedTabs(selectedTabs: Set) { + selectedTabs.forEach { + scope.launch(IO) { + val shouldAddBookmark = bookmarksStorage.getBookmarksWithUrl(it.url) + .firstOrNull { it.url == it.url } == null + if (shouldAddBookmark) { + bookmarksStorage.addItem( + BookmarkRoot.Mobile.id, + url = it.url, + title = it.title, + position = null + ) + } + } + } + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + showBookmarksSnackbar() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun handleDeleteSelectedTabs(selectedTabs: Set) { + if (browserStore.state.normalTabs.size == selectedTabs.size) { + dismissTabTrayAndNavigateHome(HomeFragment.ALL_NORMAL_TABS) + } else { + selectedTabs.map { it.id }.let { + tabsUseCases.removeTabs(it) + } + + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + showUndoSnackbarForTabs() + } + } + + override fun handleSyncedTabClicked(syncTab: SyncTab) { activity.openToBrowserAndLoad( searchTermOrURL = syncTab.active().url, newTab = true, @@ -137,7 +195,7 @@ class DefaultTabTrayController( } @OptIn(ExperimentalCoroutinesApi::class) - override fun onCloseAllTabsClicked(private: Boolean) { + override fun handleCloseAllTabsClicked(private: Boolean) { val sessionsToClose = if (private) { HomeFragment.ALL_PRIVATE_TABS } else { @@ -164,11 +222,6 @@ class DefaultTabTrayController( } } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - private fun getListOfSessions(private: Boolean): List { - return sessionManager.sessionsOfType(private = private).toList() - } - override fun onModeRequested(): TabTrayDialogFragmentState.Mode { return tabTrayDialogFragmentStore.state.mode } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt index 7cf5a127b..0c0e7f667 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.session.Session import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.state.TabSessionState @@ -71,7 +72,8 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler private val snackbarAnchor: View? get() = if (tabTrayView.fabView.new_tab_button.isVisible || - tabTrayView.mode != Mode.Normal) tabTrayView.fabView.new_tab_button + tabTrayView.mode != Mode.Normal + ) tabTrayView.fabView.new_tab_button /* During selection of the tabs to the collection, the FAB is not visible, which leads to not attaching a needed AnchorView. That's why, we're not only checking, if it's not visible, but also if we're not in a "Normal" mode, so after selecting tabs for a collection, we're pushing snackbar @@ -177,6 +179,7 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler } @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val activity = activity as HomeActivity @@ -194,8 +197,12 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler activity = activity, profiler = activity.components.core.engine.profiler, sessionManager = activity.components.core.sessionManager, + browserStore = activity.components.core.store, + tabsUseCases = activity.components.useCases.tabsUseCases, + scope = lifecycleScope, browsingModeManager = activity.browsingModeManager, tabCollectionStorage = activity.components.core.tabCollectionStorage, + bookmarksStorage = activity.components.core.bookmarksStorage, navController = findNavController(), dismissTabTray = ::dismissAllowingStateLoss, dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, @@ -203,7 +210,9 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler tabTrayDialogFragmentStore = tabTrayDialogStore, selectTabUseCase = selectTabUseCase, showChooseCollectionDialog = ::showChooseCollectionDialog, - showAddNewCollectionDialog = ::showAddNewCollectionDialog + showAddNewCollectionDialog = ::showAddNewCollectionDialog, + showUndoSnackbarForTabs = ::showUndoSnackbarForTabs, + showBookmarksSnackbar = ::showBookmarksSnackbar ) ), store = tabTrayDialogStore, @@ -267,6 +276,20 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler } } + private fun showUndoSnackbarForTabs() { + lifecycleScope.allowUndo( + requireView().tabLayout, + getString(R.string.snackbar_message_tabs_closed), + getString(R.string.snackbar_deleted_undo), + { + requireComponents.useCases.tabsUseCases.undo.invoke() + }, + operation = { }, + elevation = ELEVATION, + anchorView = snackbarAnchor + ) + } + private fun showUndoSnackbarForTab(sessionId: String) { val store = requireComponents.core.store val tab = requireComponents.core.store.state.findTab(sessionId) ?: return @@ -358,6 +381,26 @@ class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler } } + private fun showBookmarksSnackbar() { + val snackbar = FenixSnackbar + .make( + duration = FenixSnackbar.LENGTH_LONG, + isDisplayedWithBrowserToolbar = false, + view = (view as View) + ) + .setAnchorView(snackbarAnchor) + .setText(requireContext().getString(R.string.snackbar_message_bookmarks_saved)) + .setAction(requireContext().getString(R.string.snackbar_message_bookmarks_view)) { + dismissAllowingStateLoss() + findNavController().navigate( + TabTrayDialogFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id) + ) + } + + snackbar.view.elevation = ELEVATION + snackbar.show() + } + override fun onBackPressed(): Boolean { if (!tabTrayView.onBackPressed()) { dismiss() diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt index 556023572..4ca0bc882 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt @@ -22,7 +22,22 @@ interface TabTrayInteractor { /** * Called when user clicks the share tabs button. */ - fun onShareTabsClicked(private: Boolean) + fun onShareTabsOfTypeClicked(private: Boolean) + + /** + * Called when user clicks button to share selected tabs in multiselect. + */ + fun onShareSelectedTabsClicked(selectedTabs: Set) + + /** + * Called when user clicks bookmark button in menu to bookmark selected tabs in multiselect. + */ + fun onBookmarkSelectedTabs(selectedTabs: Set) + + /** + * Called when user clicks delete button in menu to delete selected tabs in multiselect. + */ + fun onDeleteSelectedTabs(selectedTabs: Set) /** * Called when user clicks the tab settings button. @@ -91,11 +106,11 @@ interface TabTrayInteractor { @Suppress("TooManyFunctions") class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor { override fun onNewTabTapped(private: Boolean) { - controller.onNewTabTapped(private) + controller.handleNewTabTapped(private) } override fun onTabTrayDismissed() { - controller.onTabTrayDismissed() + controller.handleTabTrayDismissed() } override fun onTabSettingsClicked() { @@ -106,20 +121,32 @@ class TabTrayFragmentInteractor(private val controller: TabTrayController) : Tab controller.handleRecentlyClosedClicked() } - override fun onShareTabsClicked(private: Boolean) { - controller.onShareTabsClicked(private) + override fun onShareTabsOfTypeClicked(private: Boolean) { + controller.handleShareTabsOfTypeClicked(private) + } + + override fun onShareSelectedTabsClicked(selectedTabs: Set) { + controller.handleShareSelectedTabsClicked(selectedTabs) + } + + override fun onBookmarkSelectedTabs(selectedTabs: Set) { + controller.handleBookmarkSelectedTabs(selectedTabs) + } + + override fun onDeleteSelectedTabs(selectedTabs: Set) { + controller.handleDeleteSelectedTabs(selectedTabs) } override fun onSaveToCollectionClicked(selectedTabs: Set) { - controller.onSaveToCollectionClicked(selectedTabs) + controller.handleSaveToCollectionClicked(selectedTabs) } override fun onCloseAllTabsClicked(private: Boolean) { - controller.onCloseAllTabsClicked(private) + controller.handleCloseAllTabsClicked(private) } override fun onSyncedTabClicked(syncTab: SyncTab) { - controller.onSyncedTabClicked(syncTab) + controller.handleSyncedTabClicked(syncTab) } override fun onBackPressed(): Boolean { diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayItemMenu.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayItemMenu.kt new file mode 100644 index 000000000..8a855c091 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayItemMenu.kt @@ -0,0 +1,72 @@ +/* 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.tabtray + +import android.content.Context +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components + +class TabTrayItemMenu( + private val context: Context, + private val shouldShowSaveToCollection: () -> Boolean, + private val hasOpenTabs: () -> Boolean, + private val onItemTapped: (Item) -> Unit = {} +) { + + sealed class Item { + object ShareAllTabs : Item() + object OpenTabSettings : Item() + object SaveToCollection : Item() + object CloseAllTabs : Item() + object OpenRecentlyClosed : Item() + } + + val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } + + private val menuItems by lazy { + listOf( + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_menu_item_save), + textColorResource = R.color.primary_text_normal_theme + ) { + context.components.analytics.metrics.track(Event.TabsTraySaveToCollectionPressed) + onItemTapped.invoke(Item.SaveToCollection) + }.apply { visible = shouldShowSaveToCollection }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_menu_item_share), + textColorResource = R.color.primary_text_normal_theme + ) { + context.components.analytics.metrics.track(Event.TabsTrayShareAllTabsPressed) + onItemTapped.invoke(Item.ShareAllTabs) + }.apply { visible = hasOpenTabs }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_menu_tab_settings), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.OpenTabSettings) + }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_menu_recently_closed), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.OpenRecentlyClosed) + }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_menu_item_close), + textColorResource = R.color.primary_text_normal_theme + ) { + context.components.analytics.metrics.track(Event.TabsTrayCloseAllTabsPressed) + onItemTapped.invoke(Item.CloseAllTabs) + }.apply { visible = hasOpenTabs } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index 1a7681f64..b55ae1fa7 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -30,12 +30,11 @@ import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.component_tabstray.view.* import kotlinx.android.synthetic.main.component_tabstray_fab.view.* import kotlinx.android.synthetic.main.tabs_tray_tab_counter.* +import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mozilla.components.browser.menu.BrowserMenu -import mozilla.components.browser.menu.BrowserMenuBuilder -import mozilla.components.browser.menu.item.SimpleBrowserMenuItem import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs @@ -90,6 +89,9 @@ class TabTrayView( private val tabTrayItemMenu: TabTrayItemMenu private var menu: BrowserMenu? = null + private val multiselectSelectionMenu: MultiselectSelectionMenu + private var multiselectMenu: BrowserMenu? = null + private var tabsTouchHelper: TabsTouchHelper private val collectionsButtonAdapter = SaveToCollectionsButtonAdapter(interactor, isPrivate) @@ -230,7 +232,7 @@ class TabTrayView( hasOpenTabs = checkOpenTabs ) { when (it) { - is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsClicked( + is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsOfTypeClicked( isPrivateModeSelected ) is TabTrayItemMenu.Item.OpenTabSettings -> interactor.onTabSettingsClicked() @@ -242,18 +244,30 @@ class TabTrayView( } } + multiselectSelectionMenu = MultiselectSelectionMenu( + context = view.context + ) { + when (it) { + is MultiselectSelectionMenu.Item.BookmarkTabs -> interactor.onBookmarkSelectedTabs( + mode.selectedItems + ) + is MultiselectSelectionMenu.Item.DeleteTabs -> interactor.onDeleteSelectedTabs( + mode.selectedItems + ) + } + } + view.tab_tray_overflow.setOnClickListener { components.analytics.metrics.track(Event.TabsTrayMenuOpened) menu = tabTrayItemMenu.menuBuilder.build(container.context) - menu?.show(it) - ?.also { pu -> - (pu.contentView as? CardView)?.setCardBackgroundColor( - ContextCompat.getColor( - view.context, - R.color.foundation_normal_theme - ) + menu?.show(it)?.also { popupMenu -> + (popupMenu.contentView as? CardView)?.setCardBackgroundColor( + ContextCompat.getColor( + view.context, + R.color.foundation_normal_theme ) - } + ) + } } adjustNewTabButtonsForNormalMode() @@ -469,6 +483,8 @@ class TabTrayView( fabView.new_tab_button.isVisible = false view.tab_tray_new_tab.isVisible = false view.collect_multi_select.isVisible = state.mode.selectedItems.isNotEmpty() + view.share_multi_select.isVisible = state.mode.selectedItems.isNotEmpty() + view.menu_multi_select.isVisible = state.mode.selectedItems.isNotEmpty() view.multiselect_title.text = view.context.getString( R.string.tab_tray_multi_select_title, @@ -477,6 +493,20 @@ class TabTrayView( view.collect_multi_select.setOnClickListener { interactor.onSaveToCollectionClicked(state.mode.selectedItems) } + view.share_multi_select.setOnClickListener { + interactor.onShareSelectedTabsClicked(state.mode.selectedItems) + } + view.menu_multi_select.setOnClickListener { + multiselectMenu = multiselectSelectionMenu.menuBuilder.build(container.context) + multiselectMenu?.show(it)?.also { popupMenu -> + (popupMenu.contentView as? CardView)?.setCardBackgroundColor( + ContextCompat.getColor( + view.context, + R.color.foundation_normal_theme + ) + ) + } + } view.exit_multi_select.setOnClickListener { interactor.onBackPressed() } @@ -544,6 +574,8 @@ class TabTrayView( private fun toggleUIMultiselect(multiselect: Boolean) { view.multiselect_title.isVisible = multiselect view.collect_multi_select.isVisible = multiselect + view.share_multi_select.isVisible = multiselect + view.menu_multi_select.isVisible = multiselect view.exit_multi_select.isVisible = multiselect view.topBar.setBackgroundColor( @@ -707,9 +739,7 @@ class TabTrayView( // We offset the tab index by the number of items in the other adapters. // We add the offset, because the layoutManager is initialized with `reverseLayout`. return if (view.context.settings().listTabView) { - selectedBrowserTabIndex + - collectionsButtonAdapter.itemCount + - syncedTabsController.adapter.itemCount + selectedBrowserTabIndex + collectionsButtonAdapter.itemCount + syncedTabsController.adapter.itemCount } else { selectedBrowserTabIndex } @@ -719,75 +749,18 @@ class TabTrayView( private const val TAB_COUNT_SHOW_CFR = 6 private const val DEFAULT_TAB_ID = 0 private const val PRIVATE_TAB_ID = 1 + // Minimum number of list items for which to show the tabs tray as expanded. private const val EXPAND_AT_LIST_SIZE = 4 + // Minimum number of grid items for which to show the tabs tray as expanded. private const val EXPAND_AT_GRID_SIZE = 3 private const val SLIDE_OFFSET = 0 private const val SELECTION_DELAY = 500 private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F private const val COLUMN_WIDTH_DP = 180 + // The remaining padding offset needed to provide a 16dp column spacing between the grid items. const val GRID_ITEM_PARENT_PADDING = 8 } } - -class TabTrayItemMenu( - private val context: Context, - private val shouldShowSaveToCollection: () -> Boolean, - private val hasOpenTabs: () -> Boolean, - private val onItemTapped: (Item) -> Unit = {} -) { - - sealed class Item { - object ShareAllTabs : Item() - object OpenTabSettings : Item() - object SaveToCollection : Item() - object CloseAllTabs : Item() - object OpenRecentlyClosed : Item() - } - - val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } - - private val menuItems by lazy { - listOf( - SimpleBrowserMenuItem( - context.getString(R.string.tab_tray_menu_item_save), - textColorResource = R.color.primary_text_normal_theme - ) { - context.components.analytics.metrics.track(Event.TabsTraySaveToCollectionPressed) - onItemTapped.invoke(Item.SaveToCollection) - }.apply { visible = shouldShowSaveToCollection }, - - SimpleBrowserMenuItem( - context.getString(R.string.tab_tray_menu_item_share), - textColorResource = R.color.primary_text_normal_theme - ) { - context.components.analytics.metrics.track(Event.TabsTrayShareAllTabsPressed) - onItemTapped.invoke(Item.ShareAllTabs) - }.apply { visible = hasOpenTabs }, - - SimpleBrowserMenuItem( - context.getString(R.string.tab_tray_menu_tab_settings), - textColorResource = R.color.primary_text_normal_theme - ) { - onItemTapped.invoke(Item.OpenTabSettings) - }, - - SimpleBrowserMenuItem( - context.getString(R.string.tab_tray_menu_recently_closed), - textColorResource = R.color.primary_text_normal_theme - ) { - onItemTapped.invoke(Item.OpenRecentlyClosed) - }, - - SimpleBrowserMenuItem( - context.getString(R.string.tab_tray_menu_item_close), - textColorResource = R.color.primary_text_normal_theme - ) { - context.components.analytics.metrics.track(Event.TabsTrayCloseAllTabsPressed) - onItemTapped.invoke(Item.CloseAllTabs) - }.apply { visible = hasOpenTabs } - ) - } -} diff --git a/app/src/main/res/layout/component_tabstray.xml b/app/src/main/res/layout/component_tabstray.xml index 4f14ca7e4..20aff53f0 100644 --- a/app/src/main/res/layout/component_tabstray.xml +++ b/app/src/main/res/layout/component_tabstray.xml @@ -23,14 +23,13 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintWidth_percent="0.1" /> - + android:visibility="gone" + app:layout_constraintTop_toBottomOf="@+id/topBar" /> - + + app:srcCompat="@drawable/ic_new" + app:tint="@color/primary_text_normal_theme" /> + app:srcCompat="@drawable/ic_menu" + app:tint="@color/tab_tray_heading_icon_menu_normal_theme" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/tabstray_multiselect_items.xml b/app/src/main/res/layout/tabstray_multiselect_items.xml new file mode 100644 index 000000000..07c3dfda6 --- /dev/null +++ b/app/src/main/res/layout/tabstray_multiselect_items.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96c0365aa..f8e6f2614 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -584,8 +584,18 @@ Go home Toggle tab mode + + Bookmark + + Close + + Share selected tabs + + Selected tabs menu Remove tab from collection + + Select tabs Close tab @@ -950,6 +960,12 @@ Tab closed Tabs closed + + Tabs closed! + + Bookmarks saved! + + View Added to top sites! diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt index 1d4ade685..8f3e01040 100644 --- a/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabtray/DefaultTabTrayControllerTest.kt @@ -8,6 +8,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDirections import io.mockk.Runs +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -16,9 +17,14 @@ import io.mockk.slot import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScope import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.base.profiler.Profiler +import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.concept.tabstray.Tab import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tabs.TabsUseCases @@ -40,6 +46,14 @@ class DefaultTabTrayControllerTest { private val profiler: Profiler? = mockk(relaxed = true) private val navController: NavController = mockk() private val sessionManager: SessionManager = mockk(relaxed = true) + var store = BrowserStore( + BrowserState( + tabs = listOf( + createTab(url = "http://firefox.com", id = "5678"), + createTab(url = "http://mozilla.org", id = "1234") + ), selectedTabId = "1234" + ) + ) private val browsingModeManager: BrowsingModeManager = mockk(relaxed = true) private val dismissTabTray: (() -> Unit) = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: ((String) -> Unit) = mockk(relaxed = true) @@ -47,11 +61,15 @@ class DefaultTabTrayControllerTest { private val showChooseCollectionDialog: ((List) -> Unit) = mockk(relaxed = true) private val showAddNewCollectionDialog: ((List) -> Unit) = mockk(relaxed = true) private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true) + private val bookmarksStorage: BookmarksStorage = mockk(relaxed = true) private val tabCollection: TabCollection = mockk() private val cachedTabCollections: List = listOf(tabCollection) private val currentDestination: NavDestination = mockk(relaxed = true) private val tabTrayFragmentStore: TabTrayDialogFragmentStore = mockk(relaxed = true) private val selectTabUseCase: TabsUseCases.SelectTabUseCase = mockk(relaxed = true) + private val tabsUseCases: TabsUseCases = mockk(relaxed = true) + private val showUndoSnackbarForTabs: (() -> Unit) = mockk(relaxed = true) + private val showBookmarksSavedSnackbar: (() -> Unit) = mockk(relaxed = true) private lateinit var controller: DefaultTabTrayController @@ -87,16 +105,22 @@ class DefaultTabTrayControllerTest { activity = activity, profiler = profiler, sessionManager = sessionManager, + browserStore = store, browsingModeManager = browsingModeManager, tabCollectionStorage = tabCollectionStorage, + bookmarksStorage = bookmarksStorage, + scope = TestCoroutineScope(), navController = navController, + tabsUseCases = tabsUseCases, dismissTabTray = dismissTabTray, dismissTabTrayAndNavigateHome = dismissTabTrayAndNavigateHome, registerCollectionStorageObserver = registerCollectionStorageObserver, tabTrayDialogFragmentStore = tabTrayFragmentStore, selectTabUseCase = selectTabUseCase, showChooseCollectionDialog = showChooseCollectionDialog, - showAddNewCollectionDialog = showAddNewCollectionDialog + showAddNewCollectionDialog = showAddNewCollectionDialog, + showUndoSnackbarForTabs = showUndoSnackbarForTabs, + showBookmarksSnackbar = showBookmarksSavedSnackbar ) } @@ -113,7 +137,7 @@ class DefaultTabTrayControllerTest { @Test fun onNewTabTapped() { - controller.onNewTabTapped(private = false) + controller.handleNewTabTapped(private = false) verifyOrder { browsingModeManager.mode = BrowsingMode.fromBoolean(false) @@ -125,7 +149,7 @@ class DefaultTabTrayControllerTest { dismissTabTray() } - controller.onNewTabTapped(private = true) + controller.handleNewTabTapped(private = true) verifyOrder { browsingModeManager.mode = BrowsingMode.fromBoolean(true) @@ -140,7 +164,7 @@ class DefaultTabTrayControllerTest { @Test fun onTabTrayDismissed() { - controller.onTabTrayDismissed() + controller.handleTabTrayDismissed() verify { dismissTabTray() @@ -152,7 +176,7 @@ class DefaultTabTrayControllerTest { val navDirectionsSlot = slot() every { navController.navigate(capture(navDirectionsSlot)) } just Runs - controller.onShareTabsClicked(private = false) + controller.handleShareTabsOfTypeClicked(private = false) verify { navController.navigate(capture(navDirectionsSlot)) @@ -164,7 +188,7 @@ class DefaultTabTrayControllerTest { @Test fun onCloseAllTabsClicked() { - controller.onCloseAllTabsClicked(private = false) + controller.handleCloseAllTabsClicked(private = false) verify { dismissTabTrayAndNavigateHome(any()) @@ -173,7 +197,7 @@ class DefaultTabTrayControllerTest { @Test fun onSyncedTabClicked() { - controller.onSyncedTabClicked(mockk(relaxed = true)) + controller.handleSyncedTabClicked(mockk(relaxed = true)) verify { activity.openToBrowserAndLoad(any(), true, BrowserDirection.FromTabTray) @@ -242,13 +266,53 @@ class DefaultTabTrayControllerTest { fun onSaveToCollectionClicked() { val tab = Tab("1234", "mozilla.org") - controller.onSaveToCollectionClicked(setOf(tab)) + controller.handleSaveToCollectionClicked(setOf(tab)) verify { registerCollectionStorageObserver() showChooseCollectionDialog(listOf(session)) } } + @Test + fun handleShareSelectedTabs() { + val tab = Tab("1234", "mozilla.org") + val navDirectionsSlot = slot() + every { navController.navigate(capture(navDirectionsSlot)) } just Runs + + controller.handleShareSelectedTabsClicked(setOf(tab)) + + verify { + navController.navigate(capture(navDirectionsSlot)) + } + + assertTrue(navDirectionsSlot.isCaptured) + assertEquals(R.id.action_global_shareFragment, navDirectionsSlot.captured.actionId) + } + + @Test + fun handleDeleteSelectedTabs() { + val tab = Tab("1234", "mozilla.org") + + controller.handleDeleteSelectedTabs(setOf(tab)) + verify { + tabsUseCases.removeTabs(listOf(tab.id)) + tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + showUndoSnackbarForTabs() + } + } + + @Test + fun handleBookmarkSelectedTabs() { + val tab = Tab("1234", "mozilla.org") + coEvery { bookmarksStorage.getBookmarksWithUrl("mozilla.org") } returns listOf() + + controller.handleBookmarkSelectedTabs(setOf(tab)) + verify { + tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + showBookmarksSavedSnackbar() + } + } + @Test fun handleSetUpAutoCloseTabsClicked() { controller.handleSetUpAutoCloseTabsClicked() diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt index 1e1f027e1..2d626a3b9 100644 --- a/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractorTest.kt @@ -13,13 +13,40 @@ class TabTrayFragmentInteractorTest { private val controller = mockk(relaxed = true) private val interactor = TabTrayFragmentInteractor(controller) + @Test + fun onShareSelectedTabsClicked() { + val tab = Tab("1234", "mozilla.org") + val tab2 = Tab("5678", "pocket.com") + val selectedTabs = setOf(tab, tab2) + interactor.onShareSelectedTabsClicked(selectedTabs) + verify { controller.handleShareSelectedTabsClicked(selectedTabs) } + } + + @Test + fun onBookmarkSelectedTabs() { + val tab = Tab("1234", "mozilla.org") + val tab2 = Tab("5678", "pocket.com") + val selectedTabs = setOf(tab, tab2) + interactor.onBookmarkSelectedTabs(selectedTabs) + verify { controller.handleBookmarkSelectedTabs(selectedTabs) } + } + + @Test + fun onDeleteSelectedTabs() { + val tab = Tab("1234", "mozilla.org") + val tab2 = Tab("5678", "pocket.com") + val selectedTabs = setOf(tab, tab2) + interactor.onDeleteSelectedTabs(selectedTabs) + verify { controller.handleDeleteSelectedTabs(selectedTabs) } + } + @Test fun onNewTabTapped() { interactor.onNewTabTapped(private = true) - verify { controller.onNewTabTapped(true) } + verify { controller.handleNewTabTapped(true) } interactor.onNewTabTapped(private = false) - verify { controller.onNewTabTapped(false) } + verify { controller.handleNewTabTapped(false) } } @Test @@ -34,38 +61,38 @@ class TabTrayFragmentInteractorTest { @Test fun onTabTrayDismissed() { interactor.onTabTrayDismissed() - verify { controller.onTabTrayDismissed() } + verify { controller.handleTabTrayDismissed() } } @Test fun onShareTabsClicked() { - interactor.onShareTabsClicked(private = true) - verify { controller.onShareTabsClicked(true) } + interactor.onShareTabsOfTypeClicked(private = true) + verify { controller.handleShareTabsOfTypeClicked(true) } - interactor.onShareTabsClicked(private = false) - verify { controller.onShareTabsClicked(false) } + interactor.onShareTabsOfTypeClicked(private = false) + verify { controller.handleShareTabsOfTypeClicked(false) } } @Test fun onSaveToCollectionClicked() { val tab = Tab("1234", "mozilla.org") interactor.onSaveToCollectionClicked(setOf(tab)) - verify { controller.onSaveToCollectionClicked(setOf(tab)) } + verify { controller.handleSaveToCollectionClicked(setOf(tab)) } } @Test fun onCloseAllTabsClicked() { interactor.onCloseAllTabsClicked(private = false) - verify { controller.onCloseAllTabsClicked(false) } + verify { controller.handleCloseAllTabsClicked(false) } interactor.onCloseAllTabsClicked(private = true) - verify { controller.onCloseAllTabsClicked(true) } + verify { controller.handleCloseAllTabsClicked(true) } } @Test fun onSyncedTabClicked() { interactor.onSyncedTabClicked(mockk(relaxed = true)) - verify { controller.onSyncedTabClicked(any()) } + verify { controller.handleSyncedTabClicked(any()) } } @Test