From 46511d6f8e46a41c9b03c801b326b31d1fd543cc Mon Sep 17 00:00:00 2001 From: ekager Date: Thu, 23 Jul 2020 18:56:45 -0400 Subject: [PATCH] For #10163 - Adds tab multiselect mode --- .../fenix/browser/BaseBrowserFragment.kt | 6 +- .../CollectionCreationController.kt | 35 +-- .../org/mozilla/fenix/ext/SessionManager.kt | 9 + .../org/mozilla/fenix/ext/TabCollection.kt | 15 ++ .../org/mozilla/fenix/home/HomeFragment.kt | 6 +- .../SessionControlController.kt | 28 ++- .../library/bookmarks/BookmarkFragment.kt | 3 +- .../fenix/library/history/HistoryFragment.kt | 11 +- .../fenix/tabtray/CollectionsAdapter.kt | 72 ++++++ .../mozilla/fenix/tabtray/FenixTabsAdapter.kt | 61 ++++- .../fenix/tabtray/TabTrayController.kt | 106 ++++++--- .../fenix/tabtray/TabTrayDialogFragment.kt | 162 ++++++++++++-- .../tabtray/TabTrayDialogFragmentStore.kt | 76 +++++++ .../tabtray/TabTrayFragmentInteractor.kt | 83 ++++++- .../org/mozilla/fenix/tabtray/TabTrayView.kt | 210 +++++++++++++++--- .../fenix/tabtray/TabTrayViewHolder.kt | 12 +- .../res/layout/add_new_collection_dialog.xml | 37 +++ .../layout/collection_dialog_list_item.xml | 20 ++ .../main/res/layout/component_tabstray.xml | 175 ++++++++++----- .../res/layout/name_collection_dialog.xml | 32 +++ app/src/main/res/layout/tab_tray_item.xml | 38 +++- app/src/main/res/navigation/nav_graph.xml | 11 +- app/src/main/res/values-night/colors.xml | 1 + app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 24 ++ app/src/main/res/values/styles.xml | 4 + ...DefaultCollectionCreationControllerTest.kt | 56 ----- .../mozilla/fenix/ext/SessionManagerTest.kt | 35 +++ .../mozilla/fenix/ext/TabCollectionTest.kt | 31 +++ .../DefaultSessionControlControllerTest.kt | 2 +- .../fenix/tabtray/CollectionsAdapterTest.kt | 74 ++++++ .../tabtray/DefaultTabTrayControllerTest.kt | 108 +++++++-- .../tabtray/TabTrayDialogFragmentStoreTest.kt | 145 ++++++++++++ .../tabtray/TabTrayFragmentInteractorTest.kt | 45 +++- .../fenix/tabtray/TabTrayViewHolderTest.kt | 5 +- 35 files changed, 1455 insertions(+), 286 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/CollectionsAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragmentStore.kt create mode 100644 app/src/main/res/layout/add_new_collection_dialog.xml create mode 100644 app/src/main/res/layout/collection_dialog_list_item.xml create mode 100644 app/src/main/res/layout/name_collection_dialog.xml create mode 100644 app/src/test/java/org/mozilla/fenix/tabtray/CollectionsAdapterTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabtray/TabTrayDialogFragmentStoreTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index 6eabd15c2..1a5636e99 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -107,7 +107,6 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.SharedViewModel -import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration @@ -231,7 +230,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Session tabCollectionStorage = requireComponents.core.tabCollectionStorage, topSiteStorage = requireComponents.core.topSiteStorage, onTabCounterClicked = { - TabTrayDialogFragment.show(parentFragmentManager) + findNavController().nav( + R.id.browserFragment, + BrowserFragmentDirections.actionGlobalTabTrayDialogFragment() + ) }, onCloseTab = { val snapshot = sessionManager.createSessionSnapshot(it) diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationController.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationController.kt index eecb1f761..b93775caa 100644 --- a/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationController.kt +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationController.kt @@ -15,6 +15,8 @@ import mozilla.components.feature.tab.collections.TabCollection import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.ext.getDefaultCollectionNumber +import org.mozilla.fenix.ext.normalSessionSize import org.mozilla.fenix.home.Tab interface CollectionCreationController { @@ -92,7 +94,7 @@ class DefaultCollectionCreationController( } metrics.track( - Event.CollectionSaved(normalSessionSize(sessionManager), sessionBundle.size) + Event.CollectionSaved(sessionManager.normalSessionSize(), sessionBundle.size) ) } @@ -134,7 +136,7 @@ class DefaultCollectionCreationController( } metrics.track( - Event.CollectionTabsAdded(normalSessionSize(sessionManager), sessionBundle.size) + Event.CollectionTabsAdded(sessionManager.normalSessionSize(), sessionBundle.size) ) } @@ -146,7 +148,7 @@ class DefaultCollectionCreationController( } else { SaveCollectionStep.SelectCollection }, - defaultCollectionNumber = getDefaultCollectionNumber() + defaultCollectionNumber = store.state.tabCollections.getDefaultCollectionNumber() ) ) } @@ -155,26 +157,11 @@ class DefaultCollectionCreationController( store.dispatch( CollectionCreationAction.StepChanged( SaveCollectionStep.NameCollection, - getDefaultCollectionNumber() + store.state.tabCollections.getDefaultCollectionNumber() ) ) } - /** - * Returns the new default name recommendation for a collection - * - * Algorithm: Go through all collections, make a list of their names and keep only the default ones. - * Then get the numbers from all these default names, compute the maximum number and add one. - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun getDefaultCollectionNumber(): Int { - return (store.state.tabCollections - .map { it.title } - .filter { it.matches(Regex("Collection\\s\\d+")) } - .map { Integer.valueOf(it.split(" ")[DEFAULT_COLLECTION_NUMBER_POSITION]) } - .max() ?: 0) + DEFAULT_INCREMENT_VALUE - } - override fun addTabToSelection(tab: Tab) { store.dispatch(CollectionCreationAction.TabAdded(tab)) } @@ -209,14 +196,4 @@ class DefaultCollectionCreationController( SaveCollectionStep.SelectTabs, SaveCollectionStep.RenameCollection -> null } } - - /** - * @return the number of currently active sessions that are neither custom nor private - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun normalSessionSize(sessionManager: SessionManager): Int { - return sessionManager.sessions.filter { session -> - (!session.isCustomTabSession() && !session.private) - }.size - } } diff --git a/app/src/main/java/org/mozilla/fenix/ext/SessionManager.kt b/app/src/main/java/org/mozilla/fenix/ext/SessionManager.kt index 67bfdec74..ba25e340d 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/SessionManager.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/SessionManager.kt @@ -11,3 +11,12 @@ import mozilla.components.browser.session.SessionManager */ fun SessionManager.sessionsOfType(private: Boolean) = sessions.asSequence().filter { it.private == private } + +/** + * @return the number of currently active sessions that are neither custom nor private + */ +fun SessionManager.normalSessionSize(): Int { + return this.sessions.filter { session -> + (!session.isCustomTabSession() && !session.private) + }.size +} diff --git a/app/src/main/java/org/mozilla/fenix/ext/TabCollection.kt b/app/src/main/java/org/mozilla/fenix/ext/TabCollection.kt index 8f66bd933..ad13cab6b 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/TabCollection.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/TabCollection.kt @@ -9,6 +9,7 @@ import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import mozilla.components.feature.tab.collections.TabCollection import org.mozilla.fenix.R +import org.mozilla.fenix.collections.DefaultCollectionCreationController import kotlin.math.abs /** @@ -22,3 +23,17 @@ fun TabCollection.getIconColor(context: Context): Int { iconColors.recycle() return color } + +/** + * Returns the new default name recommendation for a collection + * + * Algorithm: Go through all collections, make a list of their names and keep only the default ones. + * Then get the numbers from all these default names, compute the maximum number and add one. + */ +fun List.getDefaultCollectionNumber(): Int { + return (this + .map { it.title } + .filter { it.matches(Regex("Collection\\s\\d+")) } + .map { Integer.valueOf(it.split(" ")[DefaultCollectionCreationController.DEFAULT_COLLECTION_NUMBER_POSITION]) } + .max() ?: 0) + DefaultCollectionCreationController.DEFAULT_INCREMENT_VALUE +} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index d336bce8b..b270502b9 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -101,7 +101,6 @@ import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit -import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.utils.FragmentPreDrawManager import org.mozilla.fenix.utils.allowUndo @@ -920,7 +919,10 @@ class HomeFragment : Fragment() { } private fun openTabTray() { - TabTrayDialogFragment.show(parentFragmentManager) + findNavController().nav( + R.id.homeFragment, + HomeFragmentDirections.actionGlobalTabTrayDialogFragment() + ) } private fun updateTabCounter(browserState: BrowserState) { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt index 2cf7a5471..bb59fafcf 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt @@ -192,8 +192,12 @@ class DefaultSessionControlController( metrics.track(Event.CollectionTabRemoved) if (collection.tabs.size == 1) { - val title = activity.resources.getString(R.string.delete_tab_and_collection_dialog_title, collection.title) - val message = activity.resources.getString(R.string.delete_tab_and_collection_dialog_message) + val title = activity.resources.getString( + R.string.delete_tab_and_collection_dialog_title, + collection.title + ) + val message = + activity.resources.getString(R.string.delete_tab_and_collection_dialog_message) showDeleteCollectionPrompt(collection, title, message) } else { viewLifecycleScope.launch(Dispatchers.IO) { @@ -208,7 +212,8 @@ class DefaultSessionControlController( } override fun handleDeleteCollectionTapped(collection: TabCollection) { - val message = activity.resources.getString(R.string.tab_collection_dialog_message, collection.title) + val message = + activity.resources.getString(R.string.tab_collection_dialog_message, collection.title) showDeleteCollectionPrompt(collection, null, message) } @@ -254,8 +259,12 @@ class DefaultSessionControlController( override fun handleSelectTopSite(url: String, isDefault: Boolean) { metrics.track(Event.TopSiteOpenInNewTab) - if (isDefault) { metrics.track(Event.TopSiteOpenDefault) } - if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) } + if (isDefault) { + metrics.track(Event.TopSiteOpenDefault) + } + if (url == SupportUtils.POCKET_TRENDING_URL) { + metrics.track(Event.PocketTopSiteClicked) + } addTabUseCase.invoke( url = url, selectTab = true, @@ -297,6 +306,13 @@ class DefaultSessionControlController( fragmentStore.dispatch(HomeFragmentAction.RemoveTip(tip)) } + private fun showTabTrayCollectionCreation() { + val directions = HomeFragmentDirections.actionGlobalTabTrayDialogFragment( + enterMultiselect = true + ) + navController.nav(R.id.homeFragment, directions) + } + private fun showCollectionCreationFragment( step: SaveCollectionStep, selectedTabIds: Array? = null, @@ -322,7 +338,7 @@ class DefaultSessionControlController( } override fun handleCreateCollection() { - showCollectionCreationFragment(step = SaveCollectionStep.SelectTabs) + showTabTrayCollectionCreation() } private fun showShareFragment(data: List) { diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt index b0bce78ee..18c19943e 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt @@ -51,7 +51,6 @@ import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.library.LibraryPageFragment -import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.utils.allowUndo /** @@ -240,7 +239,7 @@ class BookmarkFragment : LibraryPageFragment(), UserInteractionHan private fun showTabTray() { invokePendingDeletion() - TabTrayDialogFragment.show(parentFragmentManager) + navigate(BookmarkFragmentDirections.actionGlobalTabTrayDialogFragment()) } private fun navigate(directions: NavDirections) { diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index 8b8179fa0..3b5f6d7d8 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -44,7 +44,6 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.library.LibraryPageFragment -import org.mozilla.fenix.tabtray.TabTrayDialogFragment import org.mozilla.fenix.utils.allowUndo @SuppressWarnings("TooManyFunctions", "LargeClass") @@ -207,7 +206,10 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl private fun showTabTray() { invokePendingDeletion() - TabTrayDialogFragment.show(parentFragmentManager) + findNavController().nav( + R.id.historyFragment, + HistoryFragmentDirections.actionGlobalTabTrayDialogFragment() + ) } private fun getMultiSelectSnackBarMessage(historyItems: Set): String { @@ -259,7 +261,10 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl launch(Main) { viewModel.invalidate() historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode) - showSnackBar(requireView(), getString(R.string.preferences_delete_browsing_data_snackbar)) + showSnackBar( + requireView(), + getString(R.string.preferences_delete_browsing_data_snackbar) + ) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/CollectionsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabtray/CollectionsAdapter.kt new file mode 100644 index 000000000..67eeba43b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/CollectionsAdapter.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.view.LayoutInflater +import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.support.ktx.android.util.dpToPx +import org.mozilla.fenix.R + +internal class CollectionsAdapter( + private val collections: Array, + private val onNewCollectionClicked: () -> Unit +) : RecyclerView.Adapter() { + + @VisibleForTesting + internal var checkedPosition = 1 + + class CollectionItemViewHolder(val textView: CheckedTextView) : + RecyclerView.ViewHolder(textView) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): CollectionItemViewHolder { + val textView = LayoutInflater.from(parent.context) + .inflate(R.layout.collection_dialog_list_item, parent, false) as CheckedTextView + return CollectionItemViewHolder(textView) + } + + override fun onBindViewHolder(holder: CollectionItemViewHolder, position: Int) { + if (position == 0) { + val displayMetrics = holder.textView.context.resources.displayMetrics + holder.textView.setPadding(NEW_COLLECTION_PADDING_START.dpToPx(displayMetrics), 0, 0, 0) + holder.textView.compoundDrawablePadding = + NEW_COLLECTION_DRAWABLE_PADDING.dpToPx(displayMetrics) + holder.textView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable( + holder.textView.context, + R.drawable.ic_new + ), null, null, null + ) + } else { + holder.textView.isChecked = checkedPosition == position + } + + holder.textView.setOnClickListener { + if (position == 0) { + onNewCollectionClicked() + } else if (checkedPosition != position) { + notifyItemChanged(position) + notifyItemChanged(checkedPosition) + checkedPosition = position + } + } + holder.textView.text = collections[position] + } + + override fun getItemCount() = collections.size + + fun getSelectedCollection() = checkedPosition - 1 + + companion object { + private const val NEW_COLLECTION_PADDING_START = 24 + private const val NEW_COLLECTION_DRAWABLE_PADDING = 28 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt index 267dc4e70..337eba60e 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/FenixTabsAdapter.kt @@ -6,14 +6,19 @@ package org.mozilla.fenix.tabtray import android.content.Context import android.view.LayoutInflater +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.tab_tray_item.view.* import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.browser.tabstray.TabsAdapter import mozilla.components.concept.tabstray.Tabs import mozilla.components.support.images.loader.ImageLoader import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics class FenixTabsAdapter( - context: Context, + private val context: Context, imageLoader: ImageLoader ) : TabsAdapter( viewHolderProvider = { parentView -> @@ -21,11 +26,19 @@ class FenixTabsAdapter( LayoutInflater.from(context).inflate( R.layout.tab_tray_item, parentView, - false), + false + ), imageLoader ) } ) { + var tabTrayInteractor: TabTrayInteractor? = null + + private val mode: TabTrayDialogFragmentState.Mode? + get() = tabTrayInteractor?.onModeRequested() + + val selectedItems get() = mode?.selectedItems ?: setOf() + var onTabsUpdated: (() -> Unit)? = null var tabCount = 0 @@ -35,9 +48,53 @@ class FenixTabsAdapter( tabCount = tabs.list.size } + override fun onBindViewHolder( + holder: TabViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isNullOrEmpty()) { + onBindViewHolder(holder, position) + return + } + // Otherwise, item needs to be checked or unchecked + val shouldBeChecked = + mode is TabTrayDialogFragmentState.Mode.MultiSelect && selectedItems.contains(holder.tab) + holder.itemView.checkmark.isVisible = shouldBeChecked + holder.itemView.selected_mask.isVisible = shouldBeChecked + } + override fun onBindViewHolder(holder: TabViewHolder, position: Int) { super.onBindViewHolder(holder, position) val newIndex = tabCount - position - 1 (holder as TabTrayViewHolder).updateAccessibilityRowIndex(holder.itemView, newIndex) + + holder.tab?.let { tab -> + val tabIsPrivate = + context.components.core.sessionManager.findSessionById(tab.id)?.private == true + if (!tabIsPrivate) { + holder.itemView.setOnLongClickListener { + if (mode is TabTrayDialogFragmentState.Mode.Normal) { + context.metrics.track(Event.CollectionTabLongPressed) + tabTrayInteractor?.onAddSelectedTab( + tab + ) + } + true + } + } + + holder.itemView.setOnClickListener { + if (mode is TabTrayDialogFragmentState.Mode.MultiSelect) { + if (mode?.selectedItems?.contains(tab) == true) { + tabTrayInteractor?.onRemoveSelectedTab(tab = tab) + } else { + tabTrayInteractor?.onAddSelectedTab(tab = tab) + } + } else { + tabTrayInteractor?.onOpenTab(tab = tab) + } + } + } } } 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 3afd4c9fe..2261e8190 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayController.kt @@ -9,10 +9,11 @@ import androidx.navigation.NavController import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.session.Session import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.tabstray.Tab +import mozilla.components.feature.tabs.TabsUseCases import org.mozilla.fenix.HomeActivity -import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.collections.SaveCollectionStep +import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.home.HomeFragment @@ -22,61 +23,81 @@ import org.mozilla.fenix.home.HomeFragment * * Delegated by View Interactors, handles container business logic and operates changes on it. */ +@Suppress("TooManyFunctions") interface TabTrayController { fun onNewTabTapped(private: Boolean) fun onTabTrayDismissed() fun onShareTabsClicked(private: Boolean) - fun onSaveToCollectionClicked() + fun onSaveToCollectionClicked(selectedTabs: Set) fun onCloseAllTabsClicked(private: Boolean) + fun handleBackPressed(): Boolean + fun onModeRequested(): TabTrayDialogFragmentState.Mode + fun handleAddSelectedTab(tab: Tab) + fun handleRemoveSelectedTab(tab: Tab) + fun handleOpenTab(tab: Tab) + fun handleEnterMultiselect() } -@Suppress("TooManyFunctions") +/** + * Default behavior of [TabTrayController]. Other implementations are possible. + * + * @param activity [HomeActivity] used for context and other Android interactions. + * @param navController [NavController] used for navigation. + * @param dismissTabTray callback allowing to request this entire Fragment to be dismissed. + * @param tabTrayDialogFragmentStore [TabTrayDialogFragmentStore] holding the State for all Views displayed + * in this Controller's Fragment. + * @param dismissTabTrayAndNavigateHome callback allowing showing an undo snackbar after tab deletion. + * @param selectTabUseCase [TabsUseCases.SelectTabUseCase] callback allowing for selecting a tab. + * @param registerCollectionStorageObserver callback allowing for registering the [TabCollectionStorage.Observer] + * when needed. + * @param showChooseCollectionDialog callback allowing saving a list of sessions to an existing collection. + * @param showAddNewCollectionDialog callback allowing for saving a list of sessions to a new collection. + */ +@Suppress("TooManyFunctions", "LongParameterList") class DefaultTabTrayController( private val activity: HomeActivity, private val navController: NavController, private val dismissTabTray: () -> Unit, private val dismissTabTrayAndNavigateHome: (String) -> Unit, - private val registerCollectionStorageObserver: () -> Unit + private val registerCollectionStorageObserver: () -> Unit, + private val tabTrayDialogFragmentStore: TabTrayDialogFragmentStore, + private val selectTabUseCase: TabsUseCases.SelectTabUseCase, + private val showChooseCollectionDialog: (List) -> Unit, + private val showAddNewCollectionDialog: (List) -> Unit ) : TabTrayController { + private val tabCollectionStorage = activity.components.core.tabCollectionStorage + override fun onNewTabTapped(private: Boolean) { val startTime = activity.components.core.engine.profiler?.getProfilerTime() activity.browsingModeManager.mode = BrowsingMode.fromBoolean(private) navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) dismissTabTray() - activity.components.core.engine.profiler?.addMarker("DefaultTabTrayController.onNewTabTapped", startTime) + activity.components.core.engine.profiler?.addMarker( + "DefaultTabTrayController.onNewTabTapped", + startTime + ) } override fun onTabTrayDismissed() { dismissTabTray() } - override fun onSaveToCollectionClicked() { - val tabs = getListOfSessions(false) - val tabIds = tabs.map { it.id }.toList().toTypedArray() - val tabCollectionStorage = activity.components.core.tabCollectionStorage - - val step = when { - // Show the SelectTabs fragment if there are multiple opened tabs to select which tabs - // you want to save to a collection. - tabs.size > 1 -> SaveCollectionStep.SelectTabs - // If there is an existing tab collection, show the SelectCollection fragment to save - // the selected tab to a collection of your choice. - tabCollectionStorage.cachedTabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection - // Show the NameCollection fragment to create a new collection for the selected tab. - else -> SaveCollectionStep.NameCollection + override fun onSaveToCollectionClicked(selectedTabs: Set) { + val sessionList = selectedTabs.map { + activity.components.core.sessionManager.findSessionById(it.id) ?: return } - if (navController.currentDestination?.id == R.id.collectionCreationFragment) return - // Only register the observer right before moving to collection creation registerCollectionStorageObserver() - val directions = TabTrayDialogFragmentDirections.actionGlobalCollectionCreationFragment( - tabIds = tabIds, - saveCollectionStep = step, - selectedTabIds = tabIds - ) - navController.navigate(directions) + when { + tabCollectionStorage.cachedTabCollections.isNotEmpty() -> { + showChooseCollectionDialog(sessionList) + } + else -> { + showAddNewCollectionDialog(sessionList) + } + } } override fun onShareTabsClicked(private: Boolean) { @@ -101,8 +122,37 @@ class DefaultTabTrayController( dismissTabTrayAndNavigateHome(sessionsToClose) } + override fun handleAddSelectedTab(tab: Tab) { + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.AddItemForCollection(tab)) + } + + override fun handleRemoveSelectedTab(tab: Tab) { + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.RemoveItemForCollection(tab)) + } + + override fun handleBackPressed(): Boolean { + return if (tabTrayDialogFragmentStore.state.mode is TabTrayDialogFragmentState.Mode.MultiSelect) { + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + true + } else { + false + } + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) private fun getListOfSessions(private: Boolean): List { return activity.components.core.sessionManager.sessionsOfType(private = private).toList() } + + override fun onModeRequested(): TabTrayDialogFragmentState.Mode { + return tabTrayDialogFragmentStore.state.mode + } + + override fun handleOpenTab(tab: Tab) { + selectTabUseCase.invoke(tab.id) + } + + override fun handleEnterMultiselect() { + tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.EnterMultiSelectMode) + } } 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 e621d9a81..f663fdd7c 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragment.kt @@ -4,23 +4,31 @@ package org.mozilla.fenix.tabtray +import android.app.Dialog import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.EditText +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDialogFragment import androidx.core.view.isVisible import androidx.core.view.updatePadding -import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.component_tabstray.view.* import kotlinx.android.synthetic.main.component_tabstray_fab.view.* import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.* import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.view.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import mozilla.components.browser.session.Session import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.thumbnails.loader.ThumbnailLoader @@ -28,24 +36,34 @@ import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.ktx.android.view.showKeyboard import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.components.FenixSnackbar +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.getDefaultCollectionNumber +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.normalSessionSize import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode import org.mozilla.fenix.utils.allowUndo @SuppressWarnings("TooManyFunctions", "LargeClass") -class TabTrayDialogFragment : AppCompatDialogFragment() { +class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { + private val args by navArgs() + private val tabsFeature = ViewBoundFeatureWrapper() private var _tabTrayView: TabTrayView? = null private val tabTrayView: TabTrayView get() = _tabTrayView!! + private lateinit var tabTrayDialogStore: TabTrayDialogFragmentStore private val snackbarAnchor: View? get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button @@ -75,6 +93,14 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { } } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return object : Dialog(requireContext(), this.theme) { + override fun onBackPressed() { + this@TabTrayDialogFragment.onBackPressed() + } + } + } + private val removeTabUseCase = object : TabsUseCases.RemoveTabUseCase { override fun invoke(sessionId: String) { requireContext().components.analytics.metrics.track(Event.ClosedExistingTab) @@ -109,7 +135,18 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false) + ): View? { + tabTrayDialogStore = StoreProvider.get(this) { + TabTrayDialogFragmentStore( + TabTrayDialogFragmentState( + requireComponents.core.store.state, + if (args.enterMultiselect) Mode.MultiSelect(setOf()) else Mode.Normal + ) + ) + } + + return inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false) + } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -140,7 +177,11 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { navController = findNavController(), dismissTabTray = ::dismissAllowingStateLoss, dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, - registerCollectionStorageObserver = ::registerCollectionStorageObserver + registerCollectionStorageObserver = ::registerCollectionStorageObserver, + tabTrayDialogFragmentStore = tabTrayDialogStore, + selectTabUseCase = selectTabUseCase, + showChooseCollectionDialog = ::showChooseCollectionDialog, + showAddNewCollectionDialog = ::showAddNewCollectionDialog ) ), isPrivate = isPrivate, @@ -188,6 +229,10 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { } consumeFrom(requireComponents.core.store) { + tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.BrowserStateChanged(it)) + } + + consumeFrom(tabTrayDialogStore) { tabTrayView.updateState(it) } } @@ -209,7 +254,8 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { } ?: return // Check if this is the last tab of this session type - val isLastOpenTab = sessionManager.sessions.filter { snapshot.session.private == it.private }.size == 1 + val isLastOpenTab = + sessionManager.sessions.filter { snapshot.session.private == it.private }.size == 1 if (isLastOpenTab) { dismissTabTrayAndNavigateHome(sessionId) @@ -295,21 +341,101 @@ class TabTrayDialogFragment : AppCompatDialogFragment() { } } - companion object { - private const val ELEVATION = 80f - private const val FRAGMENT_TAG = "tabTrayDialogFragment" + override fun onBackPressed(): Boolean { + if (!tabTrayView.onBackPressed()) { + dismiss() + } + return true + } - fun show(fragmentManager: FragmentManager) { - // If we've killed the fragmentManager. Let's not try to show the tabs tray. - if (fragmentManager.isDestroyed) { - return - } + private fun showChooseCollectionDialog(sessionList: List) { + context?.let { + val tabCollectionStorage = it.components.core.tabCollectionStorage + val collections = + tabCollectionStorage.cachedTabCollections.map { it.title }.toTypedArray() + val customLayout = + LayoutInflater.from(it).inflate(R.layout.add_new_collection_dialog, null) + val list = customLayout.findViewById(R.id.recycler_view) + list.layoutManager = LinearLayoutManager(it) - // We want to make sure we don't accidentally show the dialog twice if - // a user somehow manages to trigger `show()` twice before we present the dialog. - if (fragmentManager.findFragmentByTag(FRAGMENT_TAG) == null) { - TabTrayDialogFragment().showNow(fragmentManager, FRAGMENT_TAG) - } + val builder = AlertDialog.Builder(it).setTitle(R.string.tab_tray_select_collection) + .setView(customLayout) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + val selectedCollection = + (list.adapter as CollectionsAdapter).getSelectedCollection() + val collection = tabCollectionStorage.cachedTabCollections[selectedCollection] + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + tabCollectionStorage.addTabsToCollection(collection, sessionList) + it.metrics.track( + Event.CollectionTabsAdded( + it.components.core.sessionManager.normalSessionSize(), + sessionList.size + ) + ) + launch(Main) { + tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + dialog.dismiss() + } + } + }.setNegativeButton(android.R.string.cancel) { dialog, _ -> + tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + dialog.cancel() + } + + val dialog = builder.create() + val adapter = + CollectionsAdapter(arrayOf(it.getString(R.string.tab_tray_add_new_collection)) + collections) { + dialog.dismiss() + showAddNewCollectionDialog(sessionList) + } + list.adapter = adapter + dialog.show() } } + + private fun showAddNewCollectionDialog(sessionList: List) { + context?.let { + val tabCollectionStorage = it.components.core.tabCollectionStorage + val customLayout = + LayoutInflater.from(it).inflate(R.layout.name_collection_dialog, null) + val collectionNameEditText: EditText = + customLayout.findViewById(R.id.collection_name) + collectionNameEditText.setText( + it.getString( + R.string.create_collection_default_name, + tabCollectionStorage.cachedTabCollections.getDefaultCollectionNumber() + ) + ) + + AlertDialog.Builder(it).setTitle(R.string.tab_tray_add_new_collection) + .setView(customLayout).setPositiveButton(android.R.string.ok) { dialog, _ -> + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + tabCollectionStorage.createCollection( + collectionNameEditText.text.toString(), + sessionList + ) + it.metrics.track( + Event.CollectionSaved( + it.components.core.sessionManager.normalSessionSize(), + sessionList.size + ) + ) + launch(Main) { + tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + dialog.dismiss() + } + } + }.setNegativeButton(android.R.string.cancel) { dialog, _ -> + tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode) + dialog.cancel() + }.create().show().also { + collectionNameEditText.setSelection(0, collectionNameEditText.text.length) + collectionNameEditText.showKeyboard() + } + } + } + + companion object { + private const val ELEVATION = 80f + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragmentStore.kt new file mode 100644 index 000000000..3efb32ea4 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayDialogFragmentStore.kt @@ -0,0 +1,76 @@ +/* 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 mozilla.components.browser.state.state.BrowserState +import mozilla.components.concept.tabstray.Tab +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * The [Store] for holding the [TabTrayDialogFragmentState] and + * applying [TabTrayDialogFragmentAction]s. + */ +class TabTrayDialogFragmentStore(initialState: TabTrayDialogFragmentState) : + Store( + initialState, + ::tabTrayStateReducer + ) + +/** + * Actions to dispatch through the `TabTrayDialogFragmentStore` to modify + * `TabTrayDialogFragmentState` through the reducer. + */ +sealed class TabTrayDialogFragmentAction : Action { + data class BrowserStateChanged(val browserState: BrowserState) : TabTrayDialogFragmentAction() + object EnterMultiSelectMode : TabTrayDialogFragmentAction() + object ExitMultiSelectMode : TabTrayDialogFragmentAction() + data class AddItemForCollection(val item: Tab) : TabTrayDialogFragmentAction() + data class RemoveItemForCollection(val item: Tab) : TabTrayDialogFragmentAction() +} + +/** + * The state for the Tab Tray Dialog Screen + * @property mode Current Mode of Multiselection + */ +data class TabTrayDialogFragmentState(val browserState: BrowserState, val mode: Mode) : State { + sealed class Mode { + open val selectedItems = emptySet() + + object Normal : Mode() + data class MultiSelect(override val selectedItems: Set) : Mode() + } +} + +/** + * The TabTrayDialogFragmentState Reducer. + */ +private fun tabTrayStateReducer( + state: TabTrayDialogFragmentState, + action: TabTrayDialogFragmentAction +): TabTrayDialogFragmentState { + return when (action) { + is TabTrayDialogFragmentAction.BrowserStateChanged -> state.copy(browserState = action.browserState) + is TabTrayDialogFragmentAction.AddItemForCollection -> + state.copy(mode = TabTrayDialogFragmentState.Mode.MultiSelect(state.mode.selectedItems + action.item)) + is TabTrayDialogFragmentAction.RemoveItemForCollection -> { + val selected = state.mode.selectedItems - action.item + state.copy( + mode = if (selected.isEmpty()) { + TabTrayDialogFragmentState.Mode.Normal + } else { + TabTrayDialogFragmentState.Mode.MultiSelect(selected) + } + ) + } + is TabTrayDialogFragmentAction.ExitMultiSelectMode -> state.copy(mode = TabTrayDialogFragmentState.Mode.Normal) + is TabTrayDialogFragmentAction.EnterMultiSelectMode -> state.copy( + mode = TabTrayDialogFragmentState.Mode.MultiSelect( + setOf() + ) + ) + } +} 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 374292c8e..33bef403a 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayFragmentInteractor.kt @@ -4,17 +4,70 @@ package org.mozilla.fenix.tabtray +import mozilla.components.concept.tabstray.Tab + +@Suppress("TooManyFunctions") interface TabTrayInteractor { + /** + * Called when user clicks the new tab button. + */ fun onNewTabTapped(private: Boolean) + + /** + * Called when tab tray should be dismissed. + */ fun onTabTrayDismissed() + + /** + * Called when user clicks the share tabs button. + */ fun onShareTabsClicked(private: Boolean) - fun onSaveToCollectionClicked() + + /** + * Called when user clicks button to save selected tabs to a collection. + */ + fun onSaveToCollectionClicked(selectedTabs: Set) + + /** + * Called when user clicks the close all tabs button. + */ fun onCloseAllTabsClicked(private: Boolean) + + /** + * Called when the physical back button is clicked. + */ + fun onBackPressed(): Boolean + + /** + * Called when a requester needs to know the current mode of the tab tray. + */ + fun onModeRequested(): TabTrayDialogFragmentState.Mode + + /** + * Called when a tab should be opened in the browser. + */ + fun onOpenTab(tab: Tab) + + /** + * Called when a tab should be selected in multiselect mode. + */ + fun onAddSelectedTab(tab: Tab) + + /** + * Called when a tab should be unselected in multiselect mode. + */ + fun onRemoveSelectedTab(tab: Tab) + + /** + * Called when multiselect mode should be entered with no tabs selected. + */ + fun onEnterMultiselect() } /** * Interactor for the tab tray fragment. */ +@Suppress("TooManyFunctions") class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor { override fun onNewTabTapped(private: Boolean) { controller.onNewTabTapped(private) @@ -28,11 +81,35 @@ class TabTrayFragmentInteractor(private val controller: TabTrayController) : Tab controller.onShareTabsClicked(private) } - override fun onSaveToCollectionClicked() { - controller.onSaveToCollectionClicked() + override fun onSaveToCollectionClicked(selectedTabs: Set) { + controller.onSaveToCollectionClicked(selectedTabs) } override fun onCloseAllTabsClicked(private: Boolean) { controller.onCloseAllTabsClicked(private) } + + override fun onBackPressed(): Boolean { + return controller.handleBackPressed() + } + + override fun onModeRequested(): TabTrayDialogFragmentState.Mode { + return controller.onModeRequested() + } + + override fun onAddSelectedTab(tab: Tab) { + controller.handleAddSelectedTab(tab) + } + + override fun onRemoveSelectedTab(tab: Tab) { + controller.handleRemoveSelectedTab(tab) + } + + override fun onOpenTab(tab: Tab) { + controller.handleOpenTab(tab) + } + + override fun onEnterMultiselect() { + controller.handleEnterMultiselect() + } } 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 c0c3acecb..b580a239f 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -9,15 +9,18 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent +import androidx.annotation.IdRes import androidx.cardview.widget.CardView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.lifecycle.LifecycleCoroutineScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_tabstray.* 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.* @@ -30,6 +33,7 @@ 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.state.BrowserState +import mozilla.components.support.ktx.android.util.dpToPx import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components @@ -51,16 +55,20 @@ class TabTrayView( val fabView = LayoutInflater.from(container.context) .inflate(R.layout.component_tabstray_fab, container, true) + private val hasAccessibilityEnabled = container.context.settings().accessibilityServicesEnabled + val view = LayoutInflater.from(container.context) .inflate(R.layout.component_tabstray, container, true) - val isPrivateModeSelected: Boolean get() = view.tab_layout.selectedTabPosition == PRIVATE_TAB_ID + private val isPrivateModeSelected: Boolean get() = view.tab_layout.selectedTabPosition == PRIVATE_TAB_ID private val behavior = BottomSheetBehavior.from(view.tab_wrapper) private val tabTrayItemMenu: TabTrayItemMenu private var menu: BrowserMenu? = null + private var tabsTouchHelper: TabsTouchHelper + private var hasLoaded = false override val containerView: View? @@ -69,8 +77,6 @@ class TabTrayView( init { container.context.components.analytics.metrics.track(Event.TabsTrayOpened) - val hasAccessibilityEnabled = view.context.settings().accessibilityServicesEnabled - toggleFabText(isPrivate) behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { @@ -126,8 +132,10 @@ class TabTrayView( } adapter = tabsAdapter - TabsTouchHelper(tabsAdapter).attachToRecyclerView(this) + tabsTouchHelper = TabsTouchHelper(tabsAdapter) + tabsTouchHelper.attachToRecyclerView(this) + tabsAdapter.tabTrayInteractor = interactor tabsAdapter.onTabsUpdated = { if (hasAccessibilityEnabled) { tabsAdapter.notifyDataSetChanged() @@ -158,7 +166,7 @@ class TabTrayView( is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsClicked( isPrivateModeSelected ) - is TabTrayItemMenu.Item.SaveToCollection -> interactor.onSaveToCollectionClicked() + is TabTrayItemMenu.Item.SaveToCollection -> interactor.onEnterMultiselect() is TabTrayItemMenu.Item.CloseAllTabs -> interactor.onCloseAllTabsClicked( isPrivateModeSelected ) @@ -179,6 +187,10 @@ class TabTrayView( } } + adjustNewTabButtonsForNormalMode() + } + + private fun adjustNewTabButtonsForNormalMode() { view.tab_tray_new_tab.apply { isVisible = hasAccessibilityEnabled setOnClickListener { @@ -214,7 +226,7 @@ class TabTrayView( toggleFabText(isPrivateModeSelected) filterTabs.invoke(isPrivateModeSelected) - updateState(view.context.components.core.store.state) + updateUINormalMode(view.context.components.core.store.state) scrollToTab(view.context.components.core.store.state.selectedTabId) if (isPrivateModeSelected) { @@ -230,32 +242,168 @@ class TabTrayView( override fun onTabUnselected(tab: TabLayout.Tab?) { /*noop*/ } - fun updateState(state: BrowserState) { - view.let { - val hasNoTabs = if (isPrivateModeSelected) { - state.privateTabs.isEmpty() - } else { - state.normalTabs.isEmpty() - } + var mode: TabTrayDialogFragmentState.Mode = TabTrayDialogFragmentState.Mode.Normal + private set - view.tab_tray_empty_view.isVisible = hasNoTabs - if (hasNoTabs) { - view.tab_tray_empty_view.text = if (isPrivateModeSelected) { - view.context.getString(R.string.no_private_tabs_description) - } else { - view.context?.getString(R.string.no_open_tabs_description) + fun updateState(state: TabTrayDialogFragmentState) { + val oldMode = mode + + if (oldMode::class != state.mode::class && view.context.settings().accessibilityServicesEnabled) { + view.announceForAccessibility( + if (state.mode == TabTrayDialogFragmentState.Mode.Normal) view.context.getString( + R.string.tab_tray_exit_multiselect_content_description + ) else view.context.getString(R.string.tab_tray_enter_multiselect_content_description) + ) + } + + mode = state.mode + when (state.mode) { + TabTrayDialogFragmentState.Mode.Normal -> { + view.tabsTray.apply { + tabsTouchHelper.attachToRecyclerView(this) + } + + toggleUIMultiselect(multiselect = false) + + updateUINormalMode(state.browserState) + } + is TabTrayDialogFragmentState.Mode.MultiSelect -> { + // Disable swipe to delete while in multiselect + tabsTouchHelper.attachToRecyclerView(null) + + toggleUIMultiselect(multiselect = true) + + fabView.new_tab_button.isVisible = false + view.tab_tray_new_tab.isVisible = false + view.collect_multi_select.isVisible = state.mode.selectedItems.size > 0 + + view.multiselect_title.text = view.context.getString( + R.string.tab_tray_multi_select_title, + state.mode.selectedItems.size + ) + view.collect_multi_select.setOnClickListener { + interactor.onSaveToCollectionClicked(state.mode.selectedItems) + } + view.exit_multi_select.setOnClickListener { + interactor.onBackPressed() } } + } - view.tabsTray.visibility = if (hasNoTabs) { - View.INVISIBLE - } else { - View.VISIBLE + if (oldMode.selectedItems != state.mode.selectedItems) { + val unselectedItems = oldMode.selectedItems - state.mode.selectedItems + + state.mode.selectedItems.union(unselectedItems).forEach { item -> + if (view.context.settings().accessibilityServicesEnabled) { + view.announceForAccessibility( + if (unselectedItems.contains(item)) view.context.getString( + R.string.tab_tray_item_unselected_multiselect_content_description, + item.title + ) else view.context.getString( + R.string.tab_tray_item_selected_multiselect_content_description, + item.title + ) + ) + } + updateTabsForSelectionChanged(item.id) } - view.tab_tray_overflow.isVisible = !hasNoTabs + } + } - counter_text.text = "${state.normalTabs.size}" - updateTabCounterContentDescription(state.normalTabs.size) + private fun ConstraintLayout.setChildWPercent(percentage: Float, @IdRes childId: Int) { + this.findViewById(childId)?.let { + val constraintSet = ConstraintSet() + constraintSet.clone(this) + constraintSet.constrainPercentWidth(it.id, percentage) + constraintSet.applyTo(this) + it.requestLayout() + } + } + + private fun updateUINormalMode(browserState: BrowserState) { + val hasNoTabs = if (isPrivateModeSelected) { + browserState.privateTabs.isEmpty() + } else { + browserState.normalTabs.isEmpty() + } + + view.tab_tray_empty_view.isVisible = hasNoTabs + if (hasNoTabs) { + view.tab_tray_empty_view.text = if (isPrivateModeSelected) { + view.context.getString(R.string.no_private_tabs_description) + } else { + view.context?.getString(R.string.no_open_tabs_description) + } + } + + view.tabsTray.visibility = if (hasNoTabs) { + View.INVISIBLE + } else { + View.VISIBLE + } + view.tab_tray_overflow.isVisible = !hasNoTabs + + counter_text.text = "${browserState.normalTabs.size}" + updateTabCounterContentDescription(browserState.normalTabs.size) + + adjustNewTabButtonsForNormalMode() + } + + private fun toggleUIMultiselect(multiselect: Boolean) { + view.multiselect_title.isVisible = multiselect + view.collect_multi_select.isVisible = multiselect + view.exit_multi_select.isVisible = multiselect + + view.topBar.setBackgroundColor( + ContextCompat.getColor( + view.context, + if (multiselect) R.color.accent_normal_theme else R.color.foundation_normal_theme + ) + ) + + val displayMetrics = view.context.resources.displayMetrics + + view.handle.updateLayoutParams { + height = + if (multiselect) MULTISELECT_HANDLE_HEIGHT.dpToPx(displayMetrics) else NORMAL_HANDLE_HEIGHT.dpToPx( + displayMetrics + ) + topMargin = if (multiselect) 0.dpToPx(displayMetrics) else NORMAL_TOP_MARGIN.dpToPx( + displayMetrics + ) + } + + view.tab_wrapper.setChildWPercent( + if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH, + view.handle.id + ) + + view.handle.setBackgroundColor( + ContextCompat.getColor( + view.context, + if (multiselect) R.color.accent_normal_theme else R.color.secondary_text_normal_theme + ) + ) + + view.tab_layout.isVisible = !multiselect + view.tab_tray_empty_view.isVisible = !multiselect + view.tab_tray_overflow.isVisible = !multiselect + view.tab_layout.isVisible = !multiselect + } + + private fun updateTabsForSelectionChanged(itemId: String) { + view.tabsTray.apply { + val tabs = if (isPrivateModeSelected) { + view.context.components.core.store.state.privateTabs + } else { + view.context.components.core.store.state.normalTabs + } + + val selectedBrowserTabIndex = tabs.indexOfFirst { it.id == itemId } + + this.adapter?.notifyItemChanged( + selectedBrowserTabIndex, true + ) } } @@ -293,6 +441,10 @@ class TabTrayView( } } + fun onBackPressed(): Boolean { + return interactor.onBackPressed() + } + fun scrollToTab(sessionId: String?) { view.tabsTray.apply { val tabs = if (isPrivateModeSelected) { @@ -314,6 +466,10 @@ class TabTrayView( private const val EXPAND_AT_SIZE = 3 private const val SLIDE_OFFSET = 0 private const val SELECTION_DELAY = 500 + private const val MULTISELECT_HANDLE_HEIGHT = 11 + private const val NORMAL_HANDLE_HEIGHT = 3 + private const val NORMAL_TOP_MARGIN = 8 + private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt index 61989e696..106041472 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt @@ -46,8 +46,7 @@ class TabTrayViewHolder( itemView: View, private val imageLoader: ImageLoader, private val store: BrowserStore = itemView.context.components.core.store, - private val metrics: MetricController = itemView.context.components.analytics.metrics, - val getSelectedTabId: () -> String? = { store.state.selectedTabId } + private val metrics: MetricController = itemView.context.components.analytics.metrics ) : TabViewHolder(itemView) { private val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title) @@ -71,9 +70,6 @@ class TabTrayViewHolder( styling: TabsTrayStyling, observable: Observable ) { - // This is a hack to workaround a bug in a-c. - // https://github.com/mozilla-mobile/android-components/issues/7186 - val isSelected2 = tab.id == getSelectedTabId() this.tab = tab // Basic text @@ -82,7 +78,7 @@ class TabTrayViewHolder( updateCloseButtonDescription(tab.title) // Drawables and theme - updateBackgroundColor(isSelected2) + updateBackgroundColor(isSelected) if (tab.thumbnail != null) { thumbnailView.setImageBitmap(tab.thumbnail) @@ -144,10 +140,6 @@ class TabTrayViewHolder( } } - itemView.setOnClickListener { - observable.notifyObservers { onTabSelected(tab) } - } - closeView.setOnClickListener { observable.notifyObservers { onTabClosed(tab) } } diff --git a/app/src/main/res/layout/add_new_collection_dialog.xml b/app/src/main/res/layout/add_new_collection_dialog.xml new file mode 100644 index 000000000..99054c9f4 --- /dev/null +++ b/app/src/main/res/layout/add_new_collection_dialog.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/collection_dialog_list_item.xml b/app/src/main/res/layout/collection_dialog_list_item.xml new file mode 100644 index 000000000..7a2dc36e7 --- /dev/null +++ b/app/src/main/res/layout/collection_dialog_list_item.xml @@ -0,0 +1,20 @@ + + + diff --git a/app/src/main/res/layout/component_tabstray.xml b/app/src/main/res/layout/component_tabstray.xml index 70e3a49ce..3dd4f2ae0 100644 --- a/app/src/main/res/layout/component_tabstray.xml +++ b/app/src/main/res/layout/component_tabstray.xml @@ -2,15 +2,14 @@ - - @@ -20,81 +19,141 @@ android:layout_height="3dp" android:layout_marginTop="8dp" android:background="@color/secondary_text_normal_theme" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintWidth_percent="0.1" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_percent="0.1" /> + app:layout_constraintTop_toBottomOf="@id/topBar" /> - + app:layout_constraintTop_toBottomOf="@+id/handle"> - + + + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:textColor="@color/contrast_text_normal_theme" + android:textSize="18sp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/collect_multi_select" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintStart_toEndOf="@+id/exit_multi_select" + app:layout_constraintTop_toTopOf="parent" + tools:text="3 selected" /> - + + + android:layout_height="80dp" + android:background="@color/foundation_normal_theme" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_percent="0.5" + app:tabGravity="fill" + app:tabIconTint="@color/tab_icon" + app:tabIndicatorColor="@color/accent_normal_theme" + app:tabRippleColor="@android:color/transparent"> - + - + - + + + + + + + app:layout_constraintTop_toBottomOf="@+id/topBar" /> + + + + + + + diff --git a/app/src/main/res/layout/tab_tray_item.xml b/app/src/main/res/layout/tab_tray_item.xml index 1bcd76021..b3d9d3ddf 100644 --- a/app/src/main/res/layout/tab_tray_item.xml +++ b/app/src/main/res/layout/tab_tray_item.xml @@ -7,7 +7,9 @@ android:id="@+id/tab_item" android:layout_width="match_parent" android:layout_height="88dp" - android:focusable="true"> + android:clickable="true" + android:focusable="true" + android:foreground="?android:selectableItemBackground"> + android:layout_height="match_parent" + android:importantForAccessibility="no" + android:padding="22dp" + app:srcCompat="@drawable/mozac_ic_globe" + app:tint="?tabTrayThumbnailIcon" /> + + + + + app:srcCompat="@drawable/mozac_ic_close" + app:tint="@color/tab_tray_item_text_normal_theme" /> diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index d543ac084..c33f8f0f4 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -109,7 +109,12 @@ + tools:layout="@layout/fragment_tab_tray_dialog"> + + + tools:layout="@layout/fragment_synced_tabs" /> + tools:layout="@layout/fragment_exceptions" /> @color/tab_tray_heading_icon_inactive_dark_theme @color/tab_tray_item_thumbnail_background_dark_theme @color/tab_tray_item_thumbnail_icon_dark_theme + @color/tab_tray_selected_mask_dark_theme @color/top_site_background_dark_theme diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 21854871f..619a14af1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -85,6 +85,7 @@ @color/ink_20_48a @color/light_grey_10 @color/light_grey_60 + @color/violet_70_12a #FBFBFE @@ -144,6 +145,7 @@ @color/violet_50_48a @color/dark_grey_50 @color/dark_grey_05 + @color/violet_50_32a #FBFBFE @@ -249,6 +251,7 @@ @color/tab_tray_heading_icon_inactive_light_theme @color/tab_tray_item_thumbnail_background_light_theme @color/tab_tray_item_thumbnail_icon_light_theme + @color/tab_tray_selected_mask_light_theme #DFDFE3 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51601631d..e0ea31649 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,28 @@ 1 open tab. Tap to switch tabs. %1$s open tabs. Tap to switch tabs. + + %1$d selected + + Add new collection + + Name + + Select collection + + Exit multiselect mode + + Save selected tabs to collection + + Selected %1$s + + Unselected %1$s + + Exited multiselect mode + + Entered multiselect mode, select tabs to save to a collection + + Selected %1$s is produced by Mozilla. @@ -498,6 +520,8 @@ Remove %1$s (Private Mode) + + Save diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 144f053fe..f370730f1 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -100,6 +100,10 @@