1
0
Fork 0

For #10163 - Adds tab multiselect mode

master
ekager 2020-07-23 18:56:45 -04:00 committed by Emily Kager
parent 6c0be8db1d
commit 46511d6f8e
35 changed files with 1455 additions and 286 deletions

View File

@ -107,7 +107,6 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.SharedViewModel import org.mozilla.fenix.home.SharedViewModel
import org.mozilla.fenix.tabtray.TabTrayDialogFragment
import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration import org.mozilla.fenix.wifi.SitePermissionsWifiIntegration
@ -231,7 +230,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Session
tabCollectionStorage = requireComponents.core.tabCollectionStorage, tabCollectionStorage = requireComponents.core.tabCollectionStorage,
topSiteStorage = requireComponents.core.topSiteStorage, topSiteStorage = requireComponents.core.topSiteStorage,
onTabCounterClicked = { onTabCounterClicked = {
TabTrayDialogFragment.show(parentFragmentManager) findNavController().nav(
R.id.browserFragment,
BrowserFragmentDirections.actionGlobalTabTrayDialogFragment()
)
}, },
onCloseTab = { onCloseTab = {
val snapshot = sessionManager.createSessionSnapshot(it) val snapshot = sessionManager.createSessionSnapshot(it)

View File

@ -15,6 +15,8 @@ import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController 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 import org.mozilla.fenix.home.Tab
interface CollectionCreationController { interface CollectionCreationController {
@ -92,7 +94,7 @@ class DefaultCollectionCreationController(
} }
metrics.track( metrics.track(
Event.CollectionSaved(normalSessionSize(sessionManager), sessionBundle.size) Event.CollectionSaved(sessionManager.normalSessionSize(), sessionBundle.size)
) )
} }
@ -134,7 +136,7 @@ class DefaultCollectionCreationController(
} }
metrics.track( metrics.track(
Event.CollectionTabsAdded(normalSessionSize(sessionManager), sessionBundle.size) Event.CollectionTabsAdded(sessionManager.normalSessionSize(), sessionBundle.size)
) )
} }
@ -146,7 +148,7 @@ class DefaultCollectionCreationController(
} else { } else {
SaveCollectionStep.SelectCollection SaveCollectionStep.SelectCollection
}, },
defaultCollectionNumber = getDefaultCollectionNumber() defaultCollectionNumber = store.state.tabCollections.getDefaultCollectionNumber()
) )
) )
} }
@ -155,26 +157,11 @@ class DefaultCollectionCreationController(
store.dispatch( store.dispatch(
CollectionCreationAction.StepChanged( CollectionCreationAction.StepChanged(
SaveCollectionStep.NameCollection, 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) { override fun addTabToSelection(tab: Tab) {
store.dispatch(CollectionCreationAction.TabAdded(tab)) store.dispatch(CollectionCreationAction.TabAdded(tab))
} }
@ -209,14 +196,4 @@ class DefaultCollectionCreationController(
SaveCollectionStep.SelectTabs, SaveCollectionStep.RenameCollection -> null 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
}
} }

View File

@ -11,3 +11,12 @@ import mozilla.components.browser.session.SessionManager
*/ */
fun SessionManager.sessionsOfType(private: Boolean) = fun SessionManager.sessionsOfType(private: Boolean) =
sessions.asSequence().filter { it.private == private } 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
}

View File

@ -9,6 +9,7 @@ import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.collections.DefaultCollectionCreationController
import kotlin.math.abs import kotlin.math.abs
/** /**
@ -22,3 +23,17 @@ fun TabCollection.getIconColor(context: Context): Int {
iconColors.recycle() iconColors.recycle()
return color 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
}

View File

@ -101,7 +101,6 @@ import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP import org.mozilla.fenix.settings.SupportUtils.SumoTopic.HELP
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
import org.mozilla.fenix.tabtray.TabTrayDialogFragment
import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.FragmentPreDrawManager import org.mozilla.fenix.utils.FragmentPreDrawManager
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
@ -920,7 +919,10 @@ class HomeFragment : Fragment() {
} }
private fun openTabTray() { private fun openTabTray() {
TabTrayDialogFragment.show(parentFragmentManager) findNavController().nav(
R.id.homeFragment,
HomeFragmentDirections.actionGlobalTabTrayDialogFragment()
)
} }
private fun updateTabCounter(browserState: BrowserState) { private fun updateTabCounter(browserState: BrowserState) {

View File

@ -192,8 +192,12 @@ class DefaultSessionControlController(
metrics.track(Event.CollectionTabRemoved) metrics.track(Event.CollectionTabRemoved)
if (collection.tabs.size == 1) { if (collection.tabs.size == 1) {
val title = activity.resources.getString(R.string.delete_tab_and_collection_dialog_title, collection.title) val title = activity.resources.getString(
val message = activity.resources.getString(R.string.delete_tab_and_collection_dialog_message) 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) showDeleteCollectionPrompt(collection, title, message)
} else { } else {
viewLifecycleScope.launch(Dispatchers.IO) { viewLifecycleScope.launch(Dispatchers.IO) {
@ -208,7 +212,8 @@ class DefaultSessionControlController(
} }
override fun handleDeleteCollectionTapped(collection: TabCollection) { 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) showDeleteCollectionPrompt(collection, null, message)
} }
@ -254,8 +259,12 @@ class DefaultSessionControlController(
override fun handleSelectTopSite(url: String, isDefault: Boolean) { override fun handleSelectTopSite(url: String, isDefault: Boolean) {
metrics.track(Event.TopSiteOpenInNewTab) metrics.track(Event.TopSiteOpenInNewTab)
if (isDefault) { metrics.track(Event.TopSiteOpenDefault) } if (isDefault) {
if (url == SupportUtils.POCKET_TRENDING_URL) { metrics.track(Event.PocketTopSiteClicked) } metrics.track(Event.TopSiteOpenDefault)
}
if (url == SupportUtils.POCKET_TRENDING_URL) {
metrics.track(Event.PocketTopSiteClicked)
}
addTabUseCase.invoke( addTabUseCase.invoke(
url = url, url = url,
selectTab = true, selectTab = true,
@ -297,6 +306,13 @@ class DefaultSessionControlController(
fragmentStore.dispatch(HomeFragmentAction.RemoveTip(tip)) fragmentStore.dispatch(HomeFragmentAction.RemoveTip(tip))
} }
private fun showTabTrayCollectionCreation() {
val directions = HomeFragmentDirections.actionGlobalTabTrayDialogFragment(
enterMultiselect = true
)
navController.nav(R.id.homeFragment, directions)
}
private fun showCollectionCreationFragment( private fun showCollectionCreationFragment(
step: SaveCollectionStep, step: SaveCollectionStep,
selectedTabIds: Array<String>? = null, selectedTabIds: Array<String>? = null,
@ -322,7 +338,7 @@ class DefaultSessionControlController(
} }
override fun handleCreateCollection() { override fun handleCreateCollection() {
showCollectionCreationFragment(step = SaveCollectionStep.SelectTabs) showTabTrayCollectionCreation()
} }
private fun showShareFragment(data: List<ShareData>) { private fun showShareFragment(data: List<ShareData>) {

View File

@ -51,7 +51,6 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.tabtray.TabTrayDialogFragment
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
/** /**
@ -240,7 +239,7 @@ class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHan
private fun showTabTray() { private fun showTabTray() {
invokePendingDeletion() invokePendingDeletion()
TabTrayDialogFragment.show(parentFragmentManager) navigate(BookmarkFragmentDirections.actionGlobalTabTrayDialogFragment())
} }
private fun navigate(directions: NavDirections) { private fun navigate(directions: NavDirections) {

View File

@ -44,7 +44,6 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.tabtray.TabTrayDialogFragment
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
@ -207,7 +206,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
private fun showTabTray() { private fun showTabTray() {
invokePendingDeletion() invokePendingDeletion()
TabTrayDialogFragment.show(parentFragmentManager) findNavController().nav(
R.id.historyFragment,
HistoryFragmentDirections.actionGlobalTabTrayDialogFragment()
)
} }
private fun getMultiSelectSnackBarMessage(historyItems: Set<HistoryItem>): String { private fun getMultiSelectSnackBarMessage(historyItems: Set<HistoryItem>): String {
@ -259,7 +261,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
launch(Main) { launch(Main) {
viewModel.invalidate() viewModel.invalidate()
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode) historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
showSnackBar(requireView(), getString(R.string.preferences_delete_browsing_data_snackbar)) showSnackBar(
requireView(),
getString(R.string.preferences_delete_browsing_data_snackbar)
)
} }
} }

View File

@ -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<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
}
}

View File

@ -6,14 +6,19 @@ package org.mozilla.fenix.tabtray
import android.content.Context import android.content.Context
import android.view.LayoutInflater 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.TabViewHolder
import mozilla.components.browser.tabstray.TabsAdapter import mozilla.components.browser.tabstray.TabsAdapter
import mozilla.components.concept.tabstray.Tabs import mozilla.components.concept.tabstray.Tabs
import mozilla.components.support.images.loader.ImageLoader import mozilla.components.support.images.loader.ImageLoader
import org.mozilla.fenix.R 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( class FenixTabsAdapter(
context: Context, private val context: Context,
imageLoader: ImageLoader imageLoader: ImageLoader
) : TabsAdapter( ) : TabsAdapter(
viewHolderProvider = { parentView -> viewHolderProvider = { parentView ->
@ -21,11 +26,19 @@ class FenixTabsAdapter(
LayoutInflater.from(context).inflate( LayoutInflater.from(context).inflate(
R.layout.tab_tray_item, R.layout.tab_tray_item,
parentView, parentView,
false), false
),
imageLoader imageLoader
) )
} }
) { ) {
var tabTrayInteractor: TabTrayInteractor? = null
private val mode: TabTrayDialogFragmentState.Mode?
get() = tabTrayInteractor?.onModeRequested()
val selectedItems get() = mode?.selectedItems ?: setOf()
var onTabsUpdated: (() -> Unit)? = null var onTabsUpdated: (() -> Unit)? = null
var tabCount = 0 var tabCount = 0
@ -35,9 +48,53 @@ class FenixTabsAdapter(
tabCount = tabs.list.size 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) { override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
val newIndex = tabCount - position - 1 val newIndex = tabCount - position - 1
(holder as TabTrayViewHolder).updateAccessibilityRowIndex(holder.itemView, newIndex) (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)
}
}
}
} }
} }

View File

@ -9,10 +9,11 @@ import androidx.navigation.NavController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.concept.engine.prompt.ShareData 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.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode 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.components
import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.home.HomeFragment 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. * Delegated by View Interactors, handles container business logic and operates changes on it.
*/ */
@Suppress("TooManyFunctions")
interface TabTrayController { interface TabTrayController {
fun onNewTabTapped(private: Boolean) fun onNewTabTapped(private: Boolean)
fun onTabTrayDismissed() fun onTabTrayDismissed()
fun onShareTabsClicked(private: Boolean) fun onShareTabsClicked(private: Boolean)
fun onSaveToCollectionClicked() fun onSaveToCollectionClicked(selectedTabs: Set<Tab>)
fun onCloseAllTabsClicked(private: Boolean) 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( class DefaultTabTrayController(
private val activity: HomeActivity, private val activity: HomeActivity,
private val navController: NavController, private val navController: NavController,
private val dismissTabTray: () -> Unit, private val dismissTabTray: () -> Unit,
private val dismissTabTrayAndNavigateHome: (String) -> 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 { ) : TabTrayController {
private val tabCollectionStorage = activity.components.core.tabCollectionStorage
override fun onNewTabTapped(private: Boolean) { override fun onNewTabTapped(private: Boolean) {
val startTime = activity.components.core.engine.profiler?.getProfilerTime() val startTime = activity.components.core.engine.profiler?.getProfilerTime()
activity.browsingModeManager.mode = BrowsingMode.fromBoolean(private) activity.browsingModeManager.mode = BrowsingMode.fromBoolean(private)
navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true))
dismissTabTray() dismissTabTray()
activity.components.core.engine.profiler?.addMarker("DefaultTabTrayController.onNewTabTapped", startTime) activity.components.core.engine.profiler?.addMarker(
"DefaultTabTrayController.onNewTabTapped",
startTime
)
} }
override fun onTabTrayDismissed() { override fun onTabTrayDismissed() {
dismissTabTray() dismissTabTray()
} }
override fun onSaveToCollectionClicked() { override fun onSaveToCollectionClicked(selectedTabs: Set<Tab>) {
val tabs = getListOfSessions(false) val sessionList = selectedTabs.map {
val tabIds = tabs.map { it.id }.toList().toTypedArray() activity.components.core.sessionManager.findSessionById(it.id) ?: return
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
} }
if (navController.currentDestination?.id == R.id.collectionCreationFragment) return
// Only register the observer right before moving to collection creation // Only register the observer right before moving to collection creation
registerCollectionStorageObserver() registerCollectionStorageObserver()
val directions = TabTrayDialogFragmentDirections.actionGlobalCollectionCreationFragment( when {
tabIds = tabIds, tabCollectionStorage.cachedTabCollections.isNotEmpty() -> {
saveCollectionStep = step, showChooseCollectionDialog(sessionList)
selectedTabIds = tabIds }
) else -> {
navController.navigate(directions) showAddNewCollectionDialog(sessionList)
}
}
} }
override fun onShareTabsClicked(private: Boolean) { override fun onShareTabsClicked(private: Boolean) {
@ -101,8 +122,37 @@ class DefaultTabTrayController(
dismissTabTrayAndNavigateHome(sessionsToClose) 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) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
private fun getListOfSessions(private: Boolean): List<Session> { private fun getListOfSessions(private: Boolean): List<Session> {
return activity.components.core.sessionManager.sessionsOfType(private = private).toList() 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)
}
} }

View File

@ -4,23 +4,31 @@
package org.mozilla.fenix.tabtray package org.mozilla.fenix.tabtray
import android.app.Dialog
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController 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.view.*
import kotlinx.android.synthetic.main.component_tabstray_fab.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.*
import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.view.* 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader 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.TabsUseCases
import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.feature.tabs.tabstray.TabsFeature
import mozilla.components.lib.state.ext.consumeFrom 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.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.browser.BrowserFragmentDirections
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components 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.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
class TabTrayDialogFragment : AppCompatDialogFragment() { class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private val args by navArgs<TabTrayDialogFragmentArgs>()
private val tabsFeature = ViewBoundFeatureWrapper<TabsFeature>() private val tabsFeature = ViewBoundFeatureWrapper<TabsFeature>()
private var _tabTrayView: TabTrayView? = null private var _tabTrayView: TabTrayView? = null
private val tabTrayView: TabTrayView private val tabTrayView: TabTrayView
get() = _tabTrayView!! get() = _tabTrayView!!
private lateinit var tabTrayDialogStore: TabTrayDialogFragmentStore
private val snackbarAnchor: View? private val snackbarAnchor: View?
get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button 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 { private val removeTabUseCase = object : TabsUseCases.RemoveTabUseCase {
override fun invoke(sessionId: String) { override fun invoke(sessionId: String) {
requireContext().components.analytics.metrics.track(Event.ClosedExistingTab) requireContext().components.analytics.metrics.track(Event.ClosedExistingTab)
@ -109,7 +135,18 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? 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) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
@ -140,7 +177,11 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
navController = findNavController(), navController = findNavController(),
dismissTabTray = ::dismissAllowingStateLoss, dismissTabTray = ::dismissAllowingStateLoss,
dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome,
registerCollectionStorageObserver = ::registerCollectionStorageObserver registerCollectionStorageObserver = ::registerCollectionStorageObserver,
tabTrayDialogFragmentStore = tabTrayDialogStore,
selectTabUseCase = selectTabUseCase,
showChooseCollectionDialog = ::showChooseCollectionDialog,
showAddNewCollectionDialog = ::showAddNewCollectionDialog
) )
), ),
isPrivate = isPrivate, isPrivate = isPrivate,
@ -188,6 +229,10 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
} }
consumeFrom(requireComponents.core.store) { consumeFrom(requireComponents.core.store) {
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.BrowserStateChanged(it))
}
consumeFrom(tabTrayDialogStore) {
tabTrayView.updateState(it) tabTrayView.updateState(it)
} }
} }
@ -209,7 +254,8 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
} ?: return } ?: return
// Check if this is the last tab of this session type // 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) { if (isLastOpenTab) {
dismissTabTrayAndNavigateHome(sessionId) dismissTabTrayAndNavigateHome(sessionId)
@ -295,21 +341,101 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
} }
} }
companion object { override fun onBackPressed(): Boolean {
private const val ELEVATION = 80f if (!tabTrayView.onBackPressed()) {
private const val FRAGMENT_TAG = "tabTrayDialogFragment" dismiss()
}
return true
}
fun show(fragmentManager: FragmentManager) { private fun showChooseCollectionDialog(sessionList: List<Session>) {
// If we've killed the fragmentManager. Let's not try to show the tabs tray. context?.let {
if (fragmentManager.isDestroyed) { val tabCollectionStorage = it.components.core.tabCollectionStorage
return 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<RecyclerView>(R.id.recycler_view)
list.layoutManager = LinearLayoutManager(it)
// We want to make sure we don't accidentally show the dialog twice if val builder = AlertDialog.Builder(it).setTitle(R.string.tab_tray_select_collection)
// a user somehow manages to trigger `show()` twice before we present the dialog. .setView(customLayout)
if (fragmentManager.findFragmentByTag(FRAGMENT_TAG) == null) { .setPositiveButton(android.R.string.ok) { dialog, _ ->
TabTrayDialogFragment().showNow(fragmentManager, FRAGMENT_TAG) 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<Session>) {
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
}
} }

View File

@ -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<TabTrayDialogFragmentState, TabTrayDialogFragmentAction>(
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<Tab>()
object Normal : Mode()
data class MultiSelect(override val selectedItems: Set<Tab>) : 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()
)
)
}
}

View File

@ -4,17 +4,70 @@
package org.mozilla.fenix.tabtray package org.mozilla.fenix.tabtray
import mozilla.components.concept.tabstray.Tab
@Suppress("TooManyFunctions")
interface TabTrayInteractor { interface TabTrayInteractor {
/**
* Called when user clicks the new tab button.
*/
fun onNewTabTapped(private: Boolean) fun onNewTabTapped(private: Boolean)
/**
* Called when tab tray should be dismissed.
*/
fun onTabTrayDismissed() fun onTabTrayDismissed()
/**
* Called when user clicks the share tabs button.
*/
fun onShareTabsClicked(private: Boolean) fun onShareTabsClicked(private: Boolean)
fun onSaveToCollectionClicked()
/**
* Called when user clicks button to save selected tabs to a collection.
*/
fun onSaveToCollectionClicked(selectedTabs: Set<Tab>)
/**
* Called when user clicks the close all tabs button.
*/
fun onCloseAllTabsClicked(private: Boolean) 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. * Interactor for the tab tray fragment.
*/ */
@Suppress("TooManyFunctions")
class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor { class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor {
override fun onNewTabTapped(private: Boolean) { override fun onNewTabTapped(private: Boolean) {
controller.onNewTabTapped(private) controller.onNewTabTapped(private)
@ -28,11 +81,35 @@ class TabTrayFragmentInteractor(private val controller: TabTrayController) : Tab
controller.onShareTabsClicked(private) controller.onShareTabsClicked(private)
} }
override fun onSaveToCollectionClicked() { override fun onSaveToCollectionClicked(selectedTabs: Set<Tab>) {
controller.onSaveToCollectionClicked() controller.onSaveToCollectionClicked(selectedTabs)
} }
override fun onCloseAllTabsClicked(private: Boolean) { override fun onCloseAllTabsClicked(private: Boolean) {
controller.onCloseAllTabsClicked(private) 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()
}
} }

View File

@ -9,15 +9,18 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import androidx.annotation.IdRes
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import kotlinx.android.extensions.LayoutContainer 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.view.*
import kotlinx.android.synthetic.main.component_tabstray_fab.view.* import kotlinx.android.synthetic.main.component_tabstray_fab.view.*
import kotlinx.android.synthetic.main.tabs_tray_tab_counter.* 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.normalTabs
import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import mozilla.components.support.ktx.android.util.dpToPx
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
@ -51,16 +55,20 @@ class TabTrayView(
val fabView = LayoutInflater.from(container.context) val fabView = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray_fab, container, true) .inflate(R.layout.component_tabstray_fab, container, true)
private val hasAccessibilityEnabled = container.context.settings().accessibilityServicesEnabled
val view = LayoutInflater.from(container.context) val view = LayoutInflater.from(container.context)
.inflate(R.layout.component_tabstray, container, true) .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 behavior = BottomSheetBehavior.from(view.tab_wrapper)
private val tabTrayItemMenu: TabTrayItemMenu private val tabTrayItemMenu: TabTrayItemMenu
private var menu: BrowserMenu? = null private var menu: BrowserMenu? = null
private var tabsTouchHelper: TabsTouchHelper
private var hasLoaded = false private var hasLoaded = false
override val containerView: View? override val containerView: View?
@ -69,8 +77,6 @@ class TabTrayView(
init { init {
container.context.components.analytics.metrics.track(Event.TabsTrayOpened) container.context.components.analytics.metrics.track(Event.TabsTrayOpened)
val hasAccessibilityEnabled = view.context.settings().accessibilityServicesEnabled
toggleFabText(isPrivate) toggleFabText(isPrivate)
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
@ -126,8 +132,10 @@ class TabTrayView(
} }
adapter = tabsAdapter adapter = tabsAdapter
TabsTouchHelper(tabsAdapter).attachToRecyclerView(this) tabsTouchHelper = TabsTouchHelper(tabsAdapter)
tabsTouchHelper.attachToRecyclerView(this)
tabsAdapter.tabTrayInteractor = interactor
tabsAdapter.onTabsUpdated = { tabsAdapter.onTabsUpdated = {
if (hasAccessibilityEnabled) { if (hasAccessibilityEnabled) {
tabsAdapter.notifyDataSetChanged() tabsAdapter.notifyDataSetChanged()
@ -158,7 +166,7 @@ class TabTrayView(
is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsClicked( is TabTrayItemMenu.Item.ShareAllTabs -> interactor.onShareTabsClicked(
isPrivateModeSelected isPrivateModeSelected
) )
is TabTrayItemMenu.Item.SaveToCollection -> interactor.onSaveToCollectionClicked() is TabTrayItemMenu.Item.SaveToCollection -> interactor.onEnterMultiselect()
is TabTrayItemMenu.Item.CloseAllTabs -> interactor.onCloseAllTabsClicked( is TabTrayItemMenu.Item.CloseAllTabs -> interactor.onCloseAllTabsClicked(
isPrivateModeSelected isPrivateModeSelected
) )
@ -179,6 +187,10 @@ class TabTrayView(
} }
} }
adjustNewTabButtonsForNormalMode()
}
private fun adjustNewTabButtonsForNormalMode() {
view.tab_tray_new_tab.apply { view.tab_tray_new_tab.apply {
isVisible = hasAccessibilityEnabled isVisible = hasAccessibilityEnabled
setOnClickListener { setOnClickListener {
@ -214,7 +226,7 @@ class TabTrayView(
toggleFabText(isPrivateModeSelected) toggleFabText(isPrivateModeSelected)
filterTabs.invoke(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) scrollToTab(view.context.components.core.store.state.selectedTabId)
if (isPrivateModeSelected) { if (isPrivateModeSelected) {
@ -230,32 +242,168 @@ class TabTrayView(
override fun onTabUnselected(tab: TabLayout.Tab?) { /*noop*/ override fun onTabUnselected(tab: TabLayout.Tab?) { /*noop*/
} }
fun updateState(state: BrowserState) { var mode: TabTrayDialogFragmentState.Mode = TabTrayDialogFragmentState.Mode.Normal
view.let { private set
val hasNoTabs = if (isPrivateModeSelected) {
state.privateTabs.isEmpty()
} else {
state.normalTabs.isEmpty()
}
view.tab_tray_empty_view.isVisible = hasNoTabs fun updateState(state: TabTrayDialogFragmentState) {
if (hasNoTabs) { val oldMode = mode
view.tab_tray_empty_view.text = if (isPrivateModeSelected) {
view.context.getString(R.string.no_private_tabs_description) if (oldMode::class != state.mode::class && view.context.settings().accessibilityServicesEnabled) {
} else { view.announceForAccessibility(
view.context?.getString(R.string.no_open_tabs_description) 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) { if (oldMode.selectedItems != state.mode.selectedItems) {
View.INVISIBLE val unselectedItems = oldMode.selectedItems - state.mode.selectedItems
} else {
View.VISIBLE 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}" private fun ConstraintLayout.setChildWPercent(percentage: Float, @IdRes childId: Int) {
updateTabCounterContentDescription(state.normalTabs.size) this.findViewById<View>(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<ViewGroup.MarginLayoutParams> {
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?) { fun scrollToTab(sessionId: String?) {
view.tabsTray.apply { view.tabsTray.apply {
val tabs = if (isPrivateModeSelected) { val tabs = if (isPrivateModeSelected) {
@ -314,6 +466,10 @@ class TabTrayView(
private const val EXPAND_AT_SIZE = 3 private const val EXPAND_AT_SIZE = 3
private const val SLIDE_OFFSET = 0 private const val SLIDE_OFFSET = 0
private const val SELECTION_DELAY = 500 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
} }
} }

View File

@ -46,8 +46,7 @@ class TabTrayViewHolder(
itemView: View, itemView: View,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val store: BrowserStore = itemView.context.components.core.store, private val store: BrowserStore = itemView.context.components.core.store,
private val metrics: MetricController = itemView.context.components.analytics.metrics, private val metrics: MetricController = itemView.context.components.analytics.metrics
val getSelectedTabId: () -> String? = { store.state.selectedTabId }
) : TabViewHolder(itemView) { ) : TabViewHolder(itemView) {
private val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title) private val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title)
@ -71,9 +70,6 @@ class TabTrayViewHolder(
styling: TabsTrayStyling, styling: TabsTrayStyling,
observable: Observable<TabsTray.Observer> observable: Observable<TabsTray.Observer>
) { ) {
// 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 this.tab = tab
// Basic text // Basic text
@ -82,7 +78,7 @@ class TabTrayViewHolder(
updateCloseButtonDescription(tab.title) updateCloseButtonDescription(tab.title)
// Drawables and theme // Drawables and theme
updateBackgroundColor(isSelected2) updateBackgroundColor(isSelected)
if (tab.thumbnail != null) { if (tab.thumbnail != null) {
thumbnailView.setImageBitmap(tab.thumbnail) thumbnailView.setImageBitmap(tab.thumbnail)
@ -144,10 +140,6 @@ class TabTrayViewHolder(
} }
} }
itemView.setOnClickListener {
observable.notifyObservers { onTabSelected(tab) }
}
closeView.setOnClickListener { closeView.setOnClickListener {
observable.notifyObservers { onTabClosed(tab) } observable.notifyObservers { onTabClosed(tab) }
} }

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linear_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:id="@+id/top_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?neutralFaded" />
<ScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/collection_dialog_list_item" />
</ScrollView>
<View
android:id="@+id/bottom_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="8dp"
android:background="?neutralFaded" />
</LinearLayout>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/add_new_collection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:drawablePadding="24dp"
android:ellipsize="marquee"
android:gravity="center_vertical"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:paddingStart="20dp"
android:paddingEnd="?attr/dialogPreferredPadding"
android:text="@string/tab_tray_add_new_collection"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?primaryText"
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" />

View File

@ -2,15 +2,14 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public <!-- 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 - 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/. --> - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:mozac="http://schemas.android.com/apk/res-auto" xmlns:mozac="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tab_wrapper" android:id="@+id/tab_wrapper"
android:layout_height="match_parent"
android:layout_width="match_parent"
style="@style/BottomSheetModal" style="@style/BottomSheetModal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/foundation_normal_theme" android:backgroundTint="@color/foundation_normal_theme"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
@ -20,81 +19,141 @@
android:layout_height="3dp" android:layout_height="3dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:background="@color/secondary_text_normal_theme" android:background="@color/secondary_text_normal_theme"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.1" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.1" />
<TextView <TextView
android:id="@+id/tab_tray_empty_view" android:id="@+id/tab_tray_empty_view"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:paddingTop="80dp" android:paddingTop="80dp"
android:text="@string/no_open_tabs_description" android:text="@string/no_open_tabs_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:textColor="?secondaryText" android:textColor="?secondaryText"
android:textSize="16sp" android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tab_layout" /> app:layout_constraintTop_toBottomOf="@id/topBar" />
<com.google.android.material.tabs.TabLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/tab_layout" android:id="@+id/topBar"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="80dp"
android:background="@color/foundation_normal_theme" android:background="@color/foundation_normal_theme"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/handle">
app:layout_constraintTop_toBottomOf="@+id/handle"
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">
<com.google.android.material.tabs.TabItem <ImageButton
android:id="@+id/default_tab_item" android:id="@+id/exit_multi_select"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="0dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_tray_close_multiselect_content_description"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/multiselect_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/multiselect_title"
app:srcCompat="@drawable/ic_close"
app:tint="@color/contrast_text_normal_theme" />
<TextView
android:id="@+id/multiselect_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:contentDescription="@string/tab_header_label" android:layout_marginStart="12dp"
android:layout="@layout/tabs_tray_tab_counter" android:textColor="@color/contrast_text_normal_theme"
app:tabIconTint="@color/tab_icon" /> 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" />
<com.google.android.material.tabs.TabItem <TextView
android:id="@+id/private_tab_item" android:id="@+id/collect_multi_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_tray_collection_button_multiselect_content_description"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:text="@string/tab_tray_save_to_collection"
android:textAllCaps="true"
android:textColor="@color/contrast_text_normal_theme"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_tab_collection"
app:drawableTint="@color/contrast_text_normal_theme"
app:fontFamily="@font/metropolis_medium"
app:layout_constraintBottom_toBottomOf="@id/multiselect_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/multiselect_title" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="80dp"
android:contentDescription="@string/tabs_header_private_tabs_title" android:background="@color/foundation_normal_theme"
android:icon="@drawable/ic_private_browsing" /> 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">
</com.google.android.material.tabs.TabLayout> <com.google.android.material.tabs.TabItem
android:id="@+id/default_tab_item"
android:layout_width="0dp"
android:layout_height="match_parent"
android:contentDescription="@string/tab_header_label"
android:layout="@layout/tabs_tray_tab_counter"
app:tabIconTint="@color/tab_icon" />
<ImageButton <com.google.android.material.tabs.TabItem
android:id="@+id/tab_tray_new_tab" android:id="@+id/private_tab_item"
android:layout_width="48dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="match_parent"
android:visibility="gone" android:contentDescription="@string/tabs_header_private_tabs_title"
android:background="?android:attr/selectableItemBackgroundBorderless" android:icon="@drawable/ic_private_browsing" />
android:contentDescription="@string/add_tab"
app:srcCompat="@drawable/ic_new"
app:layout_constraintTop_toTopOf="@id/tab_layout"
app:layout_constraintEnd_toStartOf="@id/tab_tray_overflow"
app:layout_constraintBottom_toBottomOf="@id/tab_layout" />
<ImageButton </com.google.android.material.tabs.TabLayout>
android:id="@+id/tab_tray_overflow"
android:layout_width="48dp" <ImageButton
android:layout_height="48dp" android:id="@+id/tab_tray_new_tab"
android:background="?android:attr/selectableItemBackgroundBorderless" android:layout_width="48dp"
android:contentDescription="@string/open_tabs_menu" android:layout_height="48dp"
app:srcCompat="@drawable/ic_menu_tab_tray" android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginEnd="0dp" android:contentDescription="@string/add_tab"
android:visibility="visible" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="@id/tab_layout"
app:layout_constraintTop_toTopOf="@id/tab_layout" app:layout_constraintEnd_toStartOf="@id/tab_tray_overflow"
app:layout_constraintBottom_toBottomOf="@id/tab_layout" /> app:layout_constraintTop_toTopOf="@id/tab_layout"
app:srcCompat="@drawable/ic_new" />
<ImageButton
android:id="@+id/tab_tray_overflow"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="0dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/open_tabs_menu"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@id/tab_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tab_layout"
app:srcCompat="@drawable/ic_menu_tab_tray" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View <View
android:id="@+id/divider" android:id="@+id/divider"
@ -103,14 +162,14 @@
android:background="@color/tab_tray_item_divider_normal_theme" android:background="@color/tab_tray_item_divider_normal_theme"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tab_layout" /> app:layout_constraintTop_toBottomOf="@+id/topBar" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/tabsTray" android:id="@+id/tabsTray"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:paddingBottom="140dp"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="140dp"
android:scrollbars="vertical" android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/name_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="26dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:text="@string/tab_tray_add_new_collection_name"
android:textAllCaps="true"
android:textAppearance="@style/Body16TextStyle"
android:textColor="?secondaryText" />
<EditText
android:id="@+id/collection_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:backgroundTint="?neutral"
android:inputType="text"
android:singleLine="true"
android:textAlignment="viewStart" />
</LinearLayout>

View File

@ -7,7 +7,9 @@
android:id="@+id/tab_item" android:id="@+id/tab_item"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="88dp" android:layout_height="88dp"
android:focusable="true"> android:clickable="true"
android:focusable="true"
android:foreground="?android:selectableItemBackground">
<ImageButton <ImageButton
android:id="@+id/play_pause_button" android:id="@+id/play_pause_button"
@ -36,12 +38,12 @@
<ImageView <ImageView
android:id="@+id/default_tab_thumbnail" android:id="@+id/default_tab_thumbnail"
android:src="@drawable/mozac_ic_globe"
android:tint="?tabTrayThumbnailIcon"
android:padding="22dp"
android:importantForAccessibility="no"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"/> android:layout_height="match_parent"
android:importantForAccessibility="no"
android:padding="22dp"
app:srcCompat="@drawable/mozac_ic_globe"
app:tint="?tabTrayThumbnailIcon" />
<mozilla.components.browser.tabstray.thumbnail.TabThumbnailView <mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
android:id="@+id/mozac_browser_tabstray_thumbnail" android:id="@+id/mozac_browser_tabstray_thumbnail"
@ -49,6 +51,26 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@string/mozac_browser_tabstray_open_tab" /> android:contentDescription="@string/mozac_browser_tabstray_open_tab" />
<View
android:id="@+id/selected_mask"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/tab_tray_selected_mask_normal_theme"
android:visibility="gone" />
<ImageView
android:id="@+id/checkmark"
android:contentDescription="@string/tab_tray_multiselect_selected_content_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|center_vertical"
android:background="@drawable/favicon_background"
android:backgroundTint="@color/accent_normal_theme"
android:elevation="1dp"
android:padding="10dp"
android:visibility="gone"
app:srcCompat="@drawable/mozac_ic_check" />
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
<TextView <TextView
@ -88,10 +110,10 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/close_tab" android:contentDescription="@string/close_tab"
android:tint="@color/tab_tray_item_text_normal_theme"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/mozac_ic_close" /> app:srcCompat="@drawable/mozac_ic_close"
app:tint="@color/tab_tray_item_text_normal_theme" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -109,7 +109,12 @@
<dialog <dialog
android:id="@+id/tabTrayDialogFragment" android:id="@+id/tabTrayDialogFragment"
android:name="org.mozilla.fenix.tabtray.TabTrayDialogFragment" android:name="org.mozilla.fenix.tabtray.TabTrayDialogFragment"
tools:layout="@layout/fragment_tab_tray_dialog" /> tools:layout="@layout/fragment_tab_tray_dialog">
<argument
android:name="enterMultiselect"
android:defaultValue="false"
app:argType="boolean" />
</dialog>
<fragment <fragment
android:id="@+id/homeFragment" android:id="@+id/homeFragment"
@ -374,13 +379,13 @@
android:id="@+id/syncedTabsFragment" android:id="@+id/syncedTabsFragment"
android:name="org.mozilla.fenix.sync.SyncedTabsFragment" android:name="org.mozilla.fenix.sync.SyncedTabsFragment"
android:label="@string/synced_tabs" android:label="@string/synced_tabs"
tools:layout="@layout/fragment_synced_tabs"/> tools:layout="@layout/fragment_synced_tabs" />
<fragment <fragment
android:id="@+id/loginExceptionsFragment" android:id="@+id/loginExceptionsFragment"
android:name="org.mozilla.fenix.loginexceptions.LoginExceptionsFragment" android:name="org.mozilla.fenix.loginexceptions.LoginExceptionsFragment"
android:label="@string/preferences_passwords_exceptions" android:label="@string/preferences_passwords_exceptions"
tools:layout="@layout/fragment_exceptions"/> tools:layout="@layout/fragment_exceptions" />
<fragment <fragment
android:id="@+id/loginDetailFragment" android:id="@+id/loginDetailFragment"

View File

@ -60,6 +60,7 @@
<color name="tab_tray_heading_icon_inactive_normal_theme">@color/tab_tray_heading_icon_inactive_dark_theme</color> <color name="tab_tray_heading_icon_inactive_normal_theme">@color/tab_tray_heading_icon_inactive_dark_theme</color>
<color name="tab_tray_item_thumbnail_background_normal_theme">@color/tab_tray_item_thumbnail_background_dark_theme</color> <color name="tab_tray_item_thumbnail_background_normal_theme">@color/tab_tray_item_thumbnail_background_dark_theme</color>
<color name="tab_tray_item_thumbnail_icon_normal_theme">@color/tab_tray_item_thumbnail_icon_dark_theme</color> <color name="tab_tray_item_thumbnail_icon_normal_theme">@color/tab_tray_item_thumbnail_icon_dark_theme</color>
<color name="tab_tray_selected_mask_normal_theme">@color/tab_tray_selected_mask_dark_theme</color>
<!--Top site colors --> <!--Top site colors -->
<color name="top_site_background">@color/top_site_background_dark_theme</color> <color name="top_site_background">@color/top_site_background_dark_theme</color>

View File

@ -85,6 +85,7 @@
<color name="tab_tray_heading_icon_inactive_light_theme">@color/ink_20_48a</color> <color name="tab_tray_heading_icon_inactive_light_theme">@color/ink_20_48a</color>
<color name="tab_tray_item_thumbnail_background_light_theme">@color/light_grey_10</color> <color name="tab_tray_item_thumbnail_background_light_theme">@color/light_grey_10</color>
<color name="tab_tray_item_thumbnail_icon_light_theme">@color/light_grey_60</color> <color name="tab_tray_item_thumbnail_icon_light_theme">@color/light_grey_60</color>
<color name="tab_tray_selected_mask_light_theme">@color/violet_70_12a</color>
<!-- Dark theme color palette --> <!-- Dark theme color palette -->
<color name="primary_text_dark_theme">#FBFBFE</color> <color name="primary_text_dark_theme">#FBFBFE</color>
@ -144,6 +145,7 @@
<color name="tab_tray_heading_icon_inactive_dark_theme">@color/violet_50_48a</color> <color name="tab_tray_heading_icon_inactive_dark_theme">@color/violet_50_48a</color>
<color name="tab_tray_item_thumbnail_background_dark_theme">@color/dark_grey_50</color> <color name="tab_tray_item_thumbnail_background_dark_theme">@color/dark_grey_50</color>
<color name="tab_tray_item_thumbnail_icon_dark_theme">@color/dark_grey_05</color> <color name="tab_tray_item_thumbnail_icon_dark_theme">@color/dark_grey_05</color>
<color name="tab_tray_selected_mask_dark_theme">@color/violet_50_32a</color>
<!-- Private theme color palette --> <!-- Private theme color palette -->
<color name="primary_text_private_theme">#FBFBFE</color> <color name="primary_text_private_theme">#FBFBFE</color>
@ -249,6 +251,7 @@
<color name="tab_tray_heading_icon_inactive_normal_theme">@color/tab_tray_heading_icon_inactive_light_theme</color> <color name="tab_tray_heading_icon_inactive_normal_theme">@color/tab_tray_heading_icon_inactive_light_theme</color>
<color name="tab_tray_item_thumbnail_background_normal_theme">@color/tab_tray_item_thumbnail_background_light_theme</color> <color name="tab_tray_item_thumbnail_background_normal_theme">@color/tab_tray_item_thumbnail_background_light_theme</color>
<color name="tab_tray_item_thumbnail_icon_normal_theme">@color/tab_tray_item_thumbnail_icon_light_theme</color> <color name="tab_tray_item_thumbnail_icon_normal_theme">@color/tab_tray_item_thumbnail_icon_light_theme</color>
<color name="tab_tray_selected_mask_normal_theme">@color/tab_tray_selected_mask_light_theme</color>
<!-- Bookmark buttons --> <!-- Bookmark buttons -->
<color name="bookmark_favicon_background">#DFDFE3</color> <color name="bookmark_favicon_background">#DFDFE3</color>

View File

@ -23,6 +23,28 @@
<string name="open_tab_tray_single">1 open tab. Tap to switch tabs.</string> <string name="open_tab_tray_single">1 open tab. Tap to switch tabs.</string>
<!-- Message announced to the user when tab tray is selected with 0 or 2+ tabs --> <!-- Message announced to the user when tab tray is selected with 0 or 2+ tabs -->
<string name="open_tab_tray_plural">%1$s open tabs. Tap to switch tabs.</string> <string name="open_tab_tray_plural">%1$s open tabs. Tap to switch tabs.</string>
<!-- Tab tray multi select title in app bar. The first parameter is the number of tabs selected -->
<string name="tab_tray_multi_select_title">%1$d selected</string>
<!-- Label of button in create collection dialog for creating a new collection -->
<string name="tab_tray_add_new_collection">Add new collection</string>
<!-- Label of editable text in create collection dialog for naming a new collection -->
<string name="tab_tray_add_new_collection_name">Name</string>
<!-- Label of button in save to collection dialog for selecting a current collection -->
<string name="tab_tray_select_collection">Select collection</string>
<!-- Content description for close button while in multiselect mode in tab tray -->
<string name="tab_tray_close_multiselect_content_description">Exit multiselect mode</string>
<!-- Content description for save to collection button while in multiselect mode in tab tray -->
<string name="tab_tray_collection_button_multiselect_content_description">Save selected tabs to collection</string>
<!-- Content description for checkmark while tab is selected while in multiselect mode in tab tray. The first parameter is the title of the tab selected -->
<string name="tab_tray_item_selected_multiselect_content_description">Selected %1$s</string>
<!-- Content description when tab is unselected while in multiselect mode in tab tray. The first parameter is the title of the tab unselected -->
<string name="tab_tray_item_unselected_multiselect_content_description">Unselected %1$s</string>
<!-- Content description announcement when exiting multiselect mode in tab tray -->
<string name="tab_tray_exit_multiselect_content_description">Exited multiselect mode</string>
<!-- Content description announcement when entering multiselect mode in tab tray -->
<string name="tab_tray_enter_multiselect_content_description">Entered multiselect mode, select tabs to save to a collection</string>
<!-- Content description on checkmark while tab is selected in multiselect mode in tab tray -->
<string name="tab_tray_multiselect_selected_content_description">Selected</string>
<!-- About content. The first parameter is the name of the application. (For example: Fenix) --> <!-- About content. The first parameter is the name of the application. (For example: Fenix) -->
<string name="about_content">%1$s is produced by Mozilla.</string> <string name="about_content">%1$s is produced by Mozilla.</string>
@ -498,6 +520,8 @@
<string name="remove_top_site">Remove</string> <string name="remove_top_site">Remove</string>
<!-- Postfix for private WebApp titles, placeholder is replaced with app name --> <!-- Postfix for private WebApp titles, placeholder is replaced with app name -->
<string name="pwa_site_controls_title_private">%1$s (Private Mode)</string> <string name="pwa_site_controls_title_private">%1$s (Private Mode)</string>
<!-- Button in the current tab tray header in multiselect mode. Saved the selected tabs to a collection when pressed. -->
<string name="tab_tray_save_to_collection">Save</string>
<!-- History --> <!-- History -->
<!-- Text for the button to clear all history --> <!-- Text for the button to clear all history -->

View File

@ -100,6 +100,10 @@
<style name="NormalTheme" parent="NormalThemeBase" /> <style name="NormalTheme" parent="NormalThemeBase" />
<style name="BaseDialogStyle" parent="Theme.MaterialComponents.Dialog.Alert"> <style name="BaseDialogStyle" parent="Theme.MaterialComponents.Dialog.Alert">
<item name="colorControlNormal">?primaryText</item>
<item name="textColorAlertDialogListItem">?primaryText</item>
<item name="android:titleTextStyle">@style/HeaderTextStyle</item>
<item name="android:windowTitleStyle">@style/HeaderTextStyle</item>
<item name="dialogCornerRadius">@dimen/tab_corner_radius</item> <item name="dialogCornerRadius">@dimen/tab_corner_radius</item>
<item name="android:colorBackground">?above</item> <item name="android:colorBackground">?above</item>
<item name="colorAccent">?accent</item> <item name="colorAccent">?accent</item>

View File

@ -210,41 +210,6 @@ class DefaultCollectionCreationControllerTest {
assertEquals(SaveCollectionStep.SelectCollection, controller.stepBack(SaveCollectionStep.NameCollection)) assertEquals(SaveCollectionStep.SelectCollection, controller.stepBack(SaveCollectionStep.NameCollection))
} }
@Test
fun `GIVEN list of collections WHEN default collection number is required THEN return next default number`() {
val collections = mutableListOf<TabCollection>(
mockk {
every { title } returns "Collection 1"
},
mockk {
every { title } returns "Collection 2"
},
mockk {
every { title } returns "Collection 3"
}
)
state = state.copy(tabCollections = collections)
assertEquals(4, controller.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Collection 5"
})
state = state.copy(tabCollections = collections)
assertEquals(6, controller.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Random name"
})
state = state.copy(tabCollections = collections)
assertEquals(6, controller.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Collection 10 10"
})
state = state.copy(tabCollections = collections)
assertEquals(6, controller.getDefaultCollectionNumber())
}
@Test @Test
fun `WHEN adding a new collection THEN dispatch NameCollection step changed`() { fun `WHEN adding a new collection THEN dispatch NameCollection step changed`() {
controller.addNewCollection() controller.addNewCollection()
@ -275,27 +240,6 @@ class DefaultCollectionCreationControllerTest {
verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectCollection, 2)) } verify { store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.SelectCollection, 2)) }
} }
@Test
fun `normalSessionSize only counts non-private non-custom sessions`() {
val normal1 = mockSession()
val normal2 = mockSession()
val normal3 = mockSession()
val private1 = mockSession(isPrivate = true)
val private2 = mockSession(isPrivate = true)
val custom1 = mockSession(isCustom = true)
val custom2 = mockSession(isCustom = true)
val custom3 = mockSession(isCustom = true)
val privateCustom = mockSession(isPrivate = true, isCustom = true)
every { sessionManager.sessions } returns listOf(normal1, private1, private2, custom1,
normal2, normal3, custom2, custom3, privateCustom)
assertEquals(3, controller.normalSessionSize(sessionManager))
}
private fun mockSession( private fun mockSession(
sessionId: String? = null, sessionId: String? = null,
isPrivate: Boolean = false, isPrivate: Boolean = false,

View File

@ -43,9 +43,44 @@ class SessionManagerTest {
) )
} }
@Test
fun `normalSessionSize only counts non-private non-custom sessions`() {
val normal1 = mockSession()
val normal2 = mockSession()
val normal3 = mockSession()
val private1 = mockSession(isPrivate = true)
val private2 = mockSession(isPrivate = true)
val custom1 = mockSession(isCustom = true)
val custom2 = mockSession(isCustom = true)
val custom3 = mockSession(isCustom = true)
val privateCustom = mockSession(isPrivate = true, isCustom = true)
val sessionManager = mockSessionManager(
listOf(
normal1, private1, private2, custom1,
normal2, normal3, custom2, custom3, privateCustom
)
)
assertEquals(3, sessionManager.normalSessionSize())
}
private fun mockSessionManager(sessions: List<Session>): SessionManager { private fun mockSessionManager(sessions: List<Session>): SessionManager {
val sessionManager: SessionManager = mockk() val sessionManager: SessionManager = mockk()
every { sessionManager.sessions } returns sessions every { sessionManager.sessions } returns sessions
return sessionManager return sessionManager
} }
private fun mockSession(
sessionId: String? = null,
isPrivate: Boolean = false,
isCustom: Boolean = false
) = mockk<Session> {
sessionId?.let { every { id } returns it }
every { private } returns isPrivate
every { isCustomTabSession() } returns isCustom
}
} }

View File

@ -32,6 +32,37 @@ class TabCollectionTest {
assertNotEquals(defaultColor, mockTabCollection(-123L).getIconColor(testContext)) assertNotEquals(defaultColor, mockTabCollection(-123L).getIconColor(testContext))
} }
@Test
fun `GIVEN list of collections WHEN default collection number is required THEN return next default number`() {
val collections = mutableListOf<TabCollection>(
mockk {
every { title } returns "Collection 1"
},
mockk {
every { title } returns "Collection 2"
},
mockk {
every { title } returns "Collection 3"
}
)
assertEquals(4, collections.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Collection 5"
})
assertEquals(6, collections.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Random name"
})
assertEquals(6, collections.getDefaultCollectionNumber())
collections.add(mockk {
every { title } returns "Collection 10 10"
})
assertEquals(6, collections.getDefaultCollectionNumber())
}
private fun mockTabCollection(id: Long): TabCollection { private fun mockTabCollection(id: Long): TabCollection {
val collection: TabCollection = mockk() val collection: TabCollection = mockk()
every { collection.id } returns id every { collection.id } returns id

View File

@ -336,7 +336,7 @@ class DefaultSessionControlControllerTest {
verify { verify {
navController.navigate( navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment }, match<NavDirections> { it.actionId == R.id.action_global_tabTrayDialogFragment },
null null
) )
} }

View File

@ -0,0 +1,74 @@
/* 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.widget.FrameLayout
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class CollectionsAdapterTest {
private val collectionList: Array<String> =
arrayOf(
"Add new collection",
"Collection 1",
"Collection 2"
)
private val onNewCollectionClicked: () -> Unit = mockk(relaxed = true)
@Test
fun `getItemCount should return the correct list size`() {
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
assertEquals(3, adapter.itemCount)
}
@Test
fun `getSelectedCollection should account for add new collection when returning right item`() {
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
// first collection by default
assertEquals(1, adapter.checkedPosition)
assertEquals(0, adapter.getSelectedCollection())
adapter.checkedPosition = 3
assertEquals(2, adapter.getSelectedCollection())
}
@Test
fun `creates and binds viewholder`() {
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
val holder1 = adapter.createViewHolder(FrameLayout(testContext), 0)
val holder2 = adapter.createViewHolder(FrameLayout(testContext), 0)
val holder3 = adapter.createViewHolder(FrameLayout(testContext), 0)
adapter.bindViewHolder(holder1, 0)
adapter.bindViewHolder(holder2, 1)
adapter.bindViewHolder(holder3, 2)
assertEquals("Add new collection", holder1.textView.text)
holder1.textView.callOnClick()
verify {
onNewCollectionClicked()
}
assertEquals(true, holder2.textView.isChecked)
assertEquals("Collection 1", holder2.textView.text)
holder2.textView.callOnClick()
assertEquals(true, holder2.textView.isChecked)
assertEquals(false, holder3.textView.isChecked)
assertEquals("Collection 2", holder3.textView.text)
holder3.textView.callOnClick()
adapter.bindViewHolder(holder3, 2)
assertEquals(true, holder3.textView.isChecked)
}
}

View File

@ -15,9 +15,12 @@ import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.session.Session import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.tabstray.Tab
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -29,20 +32,22 @@ import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.sessionsOfType
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTabTrayControllerTest { class DefaultTabTrayControllerTest {
private val activity: HomeActivity = mockk(relaxed = true) private val activity: HomeActivity = mockk(relaxed = true)
private val navController: NavController = mockk() private val navController: NavController = mockk()
private val sessionManager: SessionManager = mockk(relaxed = true) private val sessionManager: SessionManager = mockk(relaxed = true)
private val dismissTabTray: (() -> Unit) = mockk(relaxed = true) private val dismissTabTray: (() -> Unit) = mockk(relaxed = true)
private val dismissTabTrayAndNavigateHome: ((String) -> Unit) = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: ((String) -> Unit) = mockk(relaxed = true)
private val showUndoSnackbar: ((String, SessionManager.Snapshot) -> Unit) =
mockk(relaxed = true)
private val registerCollectionStorageObserver: (() -> Unit) = mockk(relaxed = true) private val registerCollectionStorageObserver: (() -> Unit) = mockk(relaxed = true)
private val showChooseCollectionDialog: ((List<Session>) -> Unit) = mockk(relaxed = true)
private val showAddNewCollectionDialog: ((List<Session>) -> Unit) = mockk(relaxed = true)
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true) private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
private val tabCollection: TabCollection = mockk() private val tabCollection: TabCollection = mockk()
private val cachedTabCollections: List<TabCollection> = listOf(tabCollection) private val cachedTabCollections: List<TabCollection> = listOf(tabCollection)
private val currentDestination: NavDestination = mockk(relaxed = true) private val currentDestination: NavDestination = mockk(relaxed = true)
private val tabTrayFragmentStore: TabTrayDialogFragmentStore = mockk(relaxed = true)
private val selectTabUseCase: TabsUseCases.SelectTabUseCase = mockk(relaxed = true)
private lateinit var controller: DefaultTabTrayController private lateinit var controller: DefaultTabTrayController
@ -59,28 +64,34 @@ class DefaultTabTrayControllerTest {
@Before @Before
fun setUp() { fun setUp() {
mockkStatic("org.mozilla.fenix.ext.SessionManagerKt") mockkStatic("org.mozilla.fenix.ext.SessionManagerKt")
every { activity.components.core.sessionManager } returns sessionManager every { activity.components.core.sessionManager } returns sessionManager
every { activity.components.core.tabCollectionStorage } returns tabCollectionStorage every { activity.components.core.tabCollectionStorage } returns tabCollectionStorage
every { activity.components.core.engine.profiler } returns mockk(relaxed = true) every { activity.components.core.engine.profiler } returns mockk(relaxed = true)
every { sessionManager.sessionsOfType(private = true) } returns listOf(session).asSequence() every { sessionManager.sessionsOfType(private = true) } returns listOf(session).asSequence()
every { sessionManager.sessionsOfType(private = false) } returns listOf(nonPrivateSession).asSequence() every { sessionManager.sessionsOfType(private = false) } returns listOf(nonPrivateSession).asSequence()
every { sessionManager.createSessionSnapshot(any()) } returns SessionManager.Snapshot.Item( every { sessionManager.createSessionSnapshot(any()) } returns SessionManager.Snapshot.Item(
session session
) )
every { sessionManager.findSessionById("1234") } returns session
every { sessionManager.remove(any()) } just Runs every { sessionManager.remove(any()) } just Runs
every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollections every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollections
every { sessionManager.selectedSession } returns nonPrivateSession every { sessionManager.selectedSession } returns nonPrivateSession
every { navController.navigate(any<NavDirections>()) } just Runs every { navController.navigate(any<NavDirections>()) } just Runs
every { navController.currentDestination } returns currentDestination every { navController.currentDestination } returns currentDestination
every { currentDestination.id } returns R.id.browserFragment every { currentDestination.id } returns R.id.browserFragment
every { tabCollection.title } returns "Collection title"
controller = DefaultTabTrayController( controller = DefaultTabTrayController(
activity = activity, activity = activity,
navController = navController, navController = navController,
dismissTabTray = dismissTabTray, dismissTabTray = dismissTabTray,
dismissTabTrayAndNavigateHome = dismissTabTrayAndNavigateHome, dismissTabTrayAndNavigateHome = dismissTabTrayAndNavigateHome,
registerCollectionStorageObserver = registerCollectionStorageObserver registerCollectionStorageObserver = registerCollectionStorageObserver,
tabTrayDialogFragmentStore = tabTrayFragmentStore,
selectTabUseCase = selectTabUseCase,
showChooseCollectionDialog = showChooseCollectionDialog,
showAddNewCollectionDialog = showAddNewCollectionDialog
) )
} }
@ -120,24 +131,6 @@ class DefaultTabTrayControllerTest {
} }
} }
@Test
fun onSaveToCollectionClicked() {
val navDirectionsSlot = slot<NavDirections>()
every { navController.navigate(capture(navDirectionsSlot)) } just Runs
controller.onSaveToCollectionClicked()
verify {
registerCollectionStorageObserver()
navController.navigate(capture(navDirectionsSlot))
}
assertTrue(navDirectionsSlot.isCaptured)
assertEquals(
R.id.action_global_collectionCreationFragment,
navDirectionsSlot.captured.actionId
)
}
@Test @Test
fun onShareTabsClicked() { fun onShareTabsClicked() {
val navDirectionsSlot = slot<NavDirections>() val navDirectionsSlot = slot<NavDirections>()
@ -161,4 +154,73 @@ class DefaultTabTrayControllerTest {
dismissTabTrayAndNavigateHome(any()) dismissTabTrayAndNavigateHome(any())
} }
} }
@Test
fun handleBackPressed() {
every { tabTrayFragmentStore.state.mode } returns TabTrayDialogFragmentState.Mode.MultiSelect(
setOf()
)
controller.handleBackPressed()
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
}
}
@Test
fun onModeRequested() {
val mode = TabTrayDialogFragmentState.Mode.MultiSelect(
setOf()
)
every { tabTrayFragmentStore.state.mode } returns mode
controller.onModeRequested()
verify {
tabTrayFragmentStore.state.mode
}
}
@Test
fun handleAddSelectedTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleAddSelectedTab(tab)
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.AddItemForCollection(tab))
}
}
@Test
fun handleRemoveSelectedTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleRemoveSelectedTab(tab)
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.RemoveItemForCollection(tab))
}
}
@Test
fun handleOpenTab() {
val tab = Tab("1234", "mozilla.org")
controller.handleOpenTab(tab)
verify {
selectTabUseCase.invoke(tab.id)
}
}
@Test
fun handleEnterMultiselect() {
controller.handleEnterMultiselect()
verify {
tabTrayFragmentStore.dispatch(TabTrayDialogFragmentAction.EnterMultiSelectMode)
}
}
@Test
fun onSaveToCollectionClicked() {
val tab = Tab("1234", "mozilla.org")
controller.onSaveToCollectionClicked(setOf(tab))
verify {
registerCollectionStorageObserver()
showChooseCollectionDialog(listOf(session))
}
}
} }

View File

@ -0,0 +1,145 @@
/* 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 kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.concept.tabstray.Tab
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class TabTrayDialogFragmentStoreTest {
@Test
fun browserStateChange() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
val newBrowserState = BrowserState(
listOf(
createTab("https://www.mozilla.org", id = "13256")
)
)
store.dispatch(
TabTrayDialogFragmentAction.BrowserStateChanged(
newBrowserState
)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.browserState,
newBrowserState
)
}
@Test
fun enterMultiselectMode() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.EnterMultiSelectMode
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
)
}
@Test
fun exitMultiselectMode() = runBlocking {
val initialState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
)
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.ExitMultiSelectMode
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.Normal
)
assertEquals(
store.state.mode.selectedItems,
setOf<Tab>()
)
}
@Test
fun addItemForCollection() = runBlocking {
val initialState = emptyDefaultState()
val store = TabTrayDialogFragmentStore(initialState)
val tab = Tab(id = "1234", url = "mozilla.org")
store.dispatch(
TabTrayDialogFragmentAction.AddItemForCollection(tab)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab))
)
assertEquals(
store.state.mode.selectedItems,
setOf(tab)
)
}
@Test
fun removeItemForCollection() = runBlocking {
val tab = Tab(id = "1234", url = "mozilla.org")
val secondTab = Tab(id = "12345", url = "pocket.com")
val initialState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab, secondTab))
)
val store = TabTrayDialogFragmentStore(initialState)
store.dispatch(
TabTrayDialogFragmentAction.RemoveItemForCollection(tab)
).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(secondTab))
)
assertEquals(
store.state.mode.selectedItems,
setOf(secondTab)
)
store.dispatch(
TabTrayDialogFragmentAction.RemoveItemForCollection(secondTab)
).join()
assertEquals(
store.state.mode,
TabTrayDialogFragmentState.Mode.Normal
)
assertEquals(
store.state.mode.selectedItems,
setOf<Tab>()
)
}
private fun emptyDefaultState(): TabTrayDialogFragmentState = TabTrayDialogFragmentState(
browserState = BrowserState(),
mode = TabTrayDialogFragmentState.Mode.Normal
)
}

View File

@ -6,6 +6,7 @@ package org.mozilla.fenix.tabtray
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import mozilla.components.concept.tabstray.Tab
import org.junit.Test import org.junit.Test
class TabTrayFragmentInteractorTest { class TabTrayFragmentInteractorTest {
@ -38,8 +39,9 @@ class TabTrayFragmentInteractorTest {
@Test @Test
fun onSaveToCollectionClicked() { fun onSaveToCollectionClicked() {
interactor.onSaveToCollectionClicked() val tab = Tab("1234", "mozilla.org")
verify { controller.onSaveToCollectionClicked() } interactor.onSaveToCollectionClicked(setOf(tab))
verify { controller.onSaveToCollectionClicked(setOf(tab)) }
} }
@Test @Test
@ -50,4 +52,43 @@ class TabTrayFragmentInteractorTest {
interactor.onCloseAllTabsClicked(private = true) interactor.onCloseAllTabsClicked(private = true)
verify { controller.onCloseAllTabsClicked(true) } verify { controller.onCloseAllTabsClicked(true) }
} }
@Test
fun onBackPressed() {
interactor.onBackPressed()
verify { controller.handleBackPressed() }
}
@Test
fun onModeRequested() {
interactor.onModeRequested()
verify { controller.onModeRequested() }
}
@Test
fun onOpenTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onOpenTab(tab)
verify { controller.handleOpenTab(tab) }
}
@Test
fun onAddSelectedTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onAddSelectedTab(tab)
verify { controller.handleAddSelectedTab(tab) }
}
@Test
fun onRemoveSelectedTab() {
val tab = Tab("1234", "mozilla.org")
interactor.onRemoveSelectedTab(tab)
verify { controller.handleRemoveSelectedTab(tab) }
}
@Test
fun onEnterMultiselect() {
interactor.onEnterMultiselect()
verify { controller.handleEnterMultiselect() }
}
} }

View File

@ -109,11 +109,10 @@ class TabTrayViewHolderTest {
assertEquals("Pause", playPauseButtonView.contentDescription) assertEquals("Pause", playPauseButtonView.contentDescription)
} }
private fun createViewHolder(getSelectedTabId: () -> String? = { null }) = TabTrayViewHolder( private fun createViewHolder() = TabTrayViewHolder(
view, view,
imageLoader = imageLoader, imageLoader = imageLoader,
store = store, store = store,
metrics = metrics, metrics = metrics
getSelectedTabId = getSelectedTabId
) )
} }