Commit 46511d6f authored by ekager's avatar ekager Committed by Emily Kager
Browse files

For #10163 - Adds tab multiselect mode

parent 6c0be8db
......@@ -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)
......
......@@ -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
}
}
......@@ -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
}
......@@ -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<TabCollection>.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
}
......@@ -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) {
......
......@@ -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<String>? = null,
......@@ -322,7 +338,7 @@ class DefaultSessionControlController(
}
override fun handleCreateCollection() {
showCollectionCreationFragment(step = SaveCollectionStep.SelectTabs)
showTabTrayCollectionCreation()
}
private fun showShareFragment(data: List<ShareData>) {
......
......@@ -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<BookmarkNode>(), UserInteractionHan
private fun showTabTray() {
invokePendingDeletion()
TabTrayDialogFragment.show(parentFragmentManager)
navigate(BookmarkFragmentDirections.actionGlobalTabTrayDialogFragment())
}
private fun navigate(directions: NavDirections) {
......
......@@ -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<HistoryItem>(), UserInteractionHandl
private fun showTabTray() {
invokePendingDeletion()
TabTrayDialogFragment.show(parentFragmentManager)
findNavController().nav(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
)
}
private fun getMultiSelectSnackBarMessage(historyItems: Set<HistoryItem>): String {
......@@ -259,7 +261,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), 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)
)
}
}
......
/* 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<String>,
private val onNewCollectionClicked: () -> Unit
) : RecyclerView.Adapter<CollectionsAdapter.CollectionItemViewHolder>() {
@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
}
}
......@@ -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<Any>
) {
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)
}
}
}
}
}
......@@ -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<Tab>)
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<Session>) -> Unit,
private val showAddNewCollectionDialog: (List<Session>) -> 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<Tab>) {
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
}
}