From 01568d5859f98711f6d4ff18e6ec7793d30ce618 Mon Sep 17 00:00:00 2001 From: Roger Yang Date: Tue, 6 Apr 2021 10:26:41 -0400 Subject: [PATCH] Closes #18513: Re-add menu to tabs tray (#18756) --- .../fenix/tabstray/NavigationInteractor.kt | 93 +++++++++++++++ .../fenix/tabstray/TabsTrayFragment.kt | 71 +++++++++++- .../mozilla/fenix/tabstray/TabsTrayMenu.kt | 89 +++++++++++++++ .../mozilla/fenix/tabstray/ext/TabLayout.kt | 16 +++ .../tabstray/NavigationInteractorTest.kt | 106 ++++++++++++++++++ 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayMenu.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/ext/TabLayout.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt new file mode 100644 index 000000000..f4dca1449 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt @@ -0,0 +1,93 @@ +/* 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 + +import androidx.navigation.NavController +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.prompt.ShareData +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.home.HomeFragment + +/** + * For interacting with UI that is specifically for [TabsTrayFragment[]] and other browser + * tab tray views. + */ +interface NavigationInteractor { + + /** + * Called when tab tray should be dismissed. + */ + fun onTabTrayDismissed() + + /** + * Called when user clicks the share tabs button. + */ + fun onShareTabsOfTypeClicked(private: Boolean) + + /** + * Called when user clicks the tab settings button. + */ + fun onTabSettingsClicked() + + /** + * Called when user clicks the close all tabs button. + */ + fun onCloseAllTabsClicked(private: Boolean) + + /** + * Called when user clicks the recently closed tabs menu button. + */ + fun onOpenRecentlyClosedClicked() +} + +/** + * A default implementation of [NavigationInteractor]. + */ +class DefaultNavigationInteractor( + private val browserStore: BrowserStore, + private val navController: NavController, + private val metrics: MetricController, + private val dismissTabTray: () -> Unit, + private val dismissTabTrayAndNavigateHome: (String) -> Unit +) : NavigationInteractor { + + override fun onTabTrayDismissed() { + dismissTabTray() + } + + override fun onTabSettingsClicked() { + navController.navigate(TabsTrayFragmentDirections.actionGlobalTabSettingsFragment()) + } + + override fun onOpenRecentlyClosedClicked() { + navController.navigate(TabsTrayFragmentDirections.actionGlobalRecentlyClosed()) + metrics.track(Event.RecentlyClosedTabsOpened) + } + + override fun onShareTabsOfTypeClicked(private: Boolean) { + val tabs = browserStore.state.getNormalOrPrivateTabs(private) + val data = tabs.map { + ShareData(url = it.content.url, title = it.content.title) + } + val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( + data = data.toTypedArray() + ) + navController.navigate(directions) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun onCloseAllTabsClicked(private: Boolean) { + val sessionsToClose = if (private) { + HomeFragment.ALL_PRIVATE_TABS + } else { + HomeFragment.ALL_NORMAL_TABS + } + + dismissTabTrayAndNavigateHome(sessionsToClose) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index 2b020e905..e619aab58 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -10,9 +10,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatDialogFragment +import androidx.cardview.widget.CardView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.tabs.TabLayout import kotlinx.android.synthetic.main.component_tabstray2.* import kotlinx.android.synthetic.main.component_tabstray2.view.* import org.mozilla.fenix.HomeActivity @@ -21,8 +25,12 @@ import mozilla.components.browser.state.selector.normalTabs import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.ui.tabcounter.TabCounter +import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components 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.RemoveTabUseCaseWrapper @@ -31,7 +39,14 @@ import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { - lateinit var behavior: BottomSheetBehavior + private lateinit var behavior: BottomSheetBehavior + private lateinit var navigationInteractor: NavigationInteractor + + private val tabLayout: TabLayout? get() = + view?.tab_layout + + private val isPrivateModeSelected: Boolean get() = + tabLayout?.selectedTabPosition == TrayPagerAdapter.POSITION_PRIVATE_TABS private val tabLayoutMediator = ViewBoundFeatureWrapper() @@ -64,15 +79,26 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val containerView = inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false) val view: View = LayoutInflater.from(containerView.context) .inflate(R.layout.component_tabstray2, containerView as ViewGroup, true) + val activity = activity as HomeActivity behavior = BottomSheetBehavior.from(view.tab_wrapper) + navigationInteractor = + DefaultNavigationInteractor( + browserStore = activity.components.core.store, + navController = findNavController(), + metrics = activity.components.analytics.metrics, + dismissTabTray = ::dismissAllowingStateLoss, + dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome + ) + return containerView } @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupMenu(view) val browserTrayInteractor = DefaultBrowserTrayInteractor( this, @@ -148,4 +174,47 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { isUserInputEnabled = false } } + + private fun setupMenu(view: View) { + view.tab_tray_overflow.setOnClickListener { anchor -> + val tabTrayItemMenu = + TabsTrayMenu( + context = view.context, + browserStore = requireComponents.core.store, + tabLayout = tab_layout + ) { + when (it) { + is TabsTrayMenu.Item.ShareAllTabs -> + navigationInteractor.onShareTabsOfTypeClicked(isPrivateModeSelected) + is TabsTrayMenu.Item.OpenTabSettings -> + navigationInteractor.onTabSettingsClicked() + is TabsTrayMenu.Item.CloseAllTabs -> + navigationInteractor.onCloseAllTabsClicked(isPrivateModeSelected) + is TabsTrayMenu.Item.OpenRecentlyClosed -> + navigationInteractor.onOpenRecentlyClosedClicked() + is TabsTrayMenu.Item.SelectTabs -> + { /* TODO implement when mulitiselect call is available */ } + } + } + + requireComponents.analytics.metrics.track(Event.TabsTrayMenuOpened) + val menu = tabTrayItemMenu.menuBuilder.build(view.context) + menu.show(anchor).also { popupMenu -> + (popupMenu.contentView as? CardView)?.setCardBackgroundColor( + ContextCompat.getColor( + view.context, + R.color.foundation_normal_theme + ) + ) + } + } + } + private val homeViewModel: HomeScreenViewModel by activityViewModels() + + private fun dismissTabTrayAndNavigateHome(sessionId: String) { + homeViewModel.sessionToDelete = sessionId + val directions = NavGraphDirections.actionGlobalHome() + findNavController().navigate(directions) + dismissAllowingStateLoss() + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayMenu.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayMenu.kt new file mode 100644 index 000000000..c2171bde6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayMenu.kt @@ -0,0 +1,89 @@ +/* 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 + +import android.content.Context +import com.google.android.material.tabs.TabLayout +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import mozilla.components.browser.state.selector.normalTabs +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.store.BrowserStore +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.tabstray.ext.isNormalModeSelected +import org.mozilla.fenix.tabstray.ext.isPrivateModeSelected + +class TabsTrayMenu( + private val context: Context, + browserStore: BrowserStore, + private val tabLayout: TabLayout, + private val onItemTapped: (Item) -> Unit = {} +) { + + private val checkOpenTabs = + when { + tabLayout.isNormalModeSelected() -> + browserStore.state.normalTabs.isNotEmpty() + tabLayout.isPrivateModeSelected() -> + browserStore.state.privateTabs.isNotEmpty() + else -> + false + } + + private val shouldShowSelectOrShare = { tabLayout.isNormalModeSelected() && checkOpenTabs } + + sealed class Item { + object ShareAllTabs : Item() + object OpenTabSettings : Item() + object SelectTabs : Item() + object CloseAllTabs : Item() + object OpenRecentlyClosed : Item() + } + + val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } + + private val menuItems by lazy { + listOf( + SimpleBrowserMenuItem( + context.getString(R.string.tabs_tray_select_tabs), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.SelectTabs) + }.apply { visible = shouldShowSelectOrShare }, + + 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 = shouldShowSelectOrShare }, + + 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 = { checkOpenTabs } } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabLayout.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabLayout.kt new file mode 100644 index 000000000..0d5aae3cc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/TabLayout.kt @@ -0,0 +1,16 @@ +/* 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 com.google.android.material.tabs.TabLayout +import org.mozilla.fenix.tabstray.TrayPagerAdapter + +fun TabLayout.isNormalModeSelected(): Boolean { + return selectedTabPosition == TrayPagerAdapter.POSITION_NORMAL_TABS +} + +fun TabLayout.isPrivateModeSelected(): Boolean { + return selectedTabPosition == TrayPagerAdapter.POSITION_PRIVATE_TABS +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt new file mode 100644 index 000000000..4aee26111 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt @@ -0,0 +1,106 @@ +/* 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 + +import androidx.navigation.NavController +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.store.BrowserStore +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.components.metrics.MetricController + +class NavigationInteractorTest { + private lateinit var store: BrowserStore + private lateinit var navigationInteractor: NavigationInteractor + private val testTab: TabSessionState = createTab(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) + + @Before + fun setup() { + store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab))) + navigationInteractor = DefaultNavigationInteractor( + store, + navController, + metrics, + dismissTabTray, + dismissTabTrayAndNavigateHome + ) + } + + @Test + fun `navigation interactor calls the overridden functions`() { + var tabTrayDismissed = false + var tabSettingsClicked = false + var openRecentlyClosedClicked = false + var shareTabsOfTypeClicked = false + var closeAllTabsClicked = false + + class TestNavigationInteractor : NavigationInteractor { + + override fun onTabTrayDismissed() { + tabTrayDismissed = true + } + + override fun onTabSettingsClicked() { + tabSettingsClicked = true + } + + override fun onOpenRecentlyClosedClicked() { + openRecentlyClosedClicked = true + } + + override fun onShareTabsOfTypeClicked(private: Boolean) { + shareTabsOfTypeClicked = true + } + + override fun onCloseAllTabsClicked(private: Boolean) { + closeAllTabsClicked = true + } + } + + val navigationInteractor: NavigationInteractor = TestNavigationInteractor() + navigationInteractor.onTabTrayDismissed() + assert(tabTrayDismissed) + navigationInteractor.onTabSettingsClicked() + assert(tabSettingsClicked) + navigationInteractor.onOpenRecentlyClosedClicked() + assert(openRecentlyClosedClicked) + navigationInteractor.onShareTabsOfTypeClicked(true) + assert(shareTabsOfTypeClicked) + navigationInteractor.onCloseAllTabsClicked(true) + assert(closeAllTabsClicked) + } + + @Test + fun `onTabTrayDismissed calls dismissTabTray on DefaultNaviationInteractor`() { + navigationInteractor.onTabTrayDismissed() + verify(exactly = 1) { dismissTabTray() } + } + + @Test + fun `onTabSettingsClicked calls navigation on DefaultNaviationInteractor`() { + navigationInteractor.onTabSettingsClicked() + verify(exactly = 1) { navController.navigate(TabsTrayFragmentDirections.actionGlobalTabSettingsFragment()) } + } + + @Test + fun `onOpenRecentlyClosedClicked calls navigation on DefaultNaviationInteractor`() { + navigationInteractor.onOpenRecentlyClosedClicked() + verify(exactly = 1) { navController.navigate(TabsTrayFragmentDirections.actionGlobalRecentlyClosed()) } + } + + @Test + fun `onCloseAllTabsClicked calls navigation on DefaultNaviationInteractor`() { + navigationInteractor.onCloseAllTabsClicked(false) + verify(exactly = 1) { dismissTabTrayAndNavigateHome(any()) } + } +}