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

View File

@ -15,6 +15,8 @@ import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.getDefaultCollectionNumber
import org.mozilla.fenix.ext.normalSessionSize
import org.mozilla.fenix.home.Tab
interface CollectionCreationController {
@ -92,7 +94,7 @@ class DefaultCollectionCreationController(
}
metrics.track(
Event.CollectionSaved(normalSessionSize(sessionManager), sessionBundle.size)
Event.CollectionSaved(sessionManager.normalSessionSize(), sessionBundle.size)
)
}
@ -134,7 +136,7 @@ class DefaultCollectionCreationController(
}
metrics.track(
Event.CollectionTabsAdded(normalSessionSize(sessionManager), sessionBundle.size)
Event.CollectionTabsAdded(sessionManager.normalSessionSize(), sessionBundle.size)
)
}
@ -146,7 +148,7 @@ class DefaultCollectionCreationController(
} else {
SaveCollectionStep.SelectCollection
},
defaultCollectionNumber = getDefaultCollectionNumber()
defaultCollectionNumber = store.state.tabCollections.getDefaultCollectionNumber()
)
)
}
@ -155,26 +157,11 @@ class DefaultCollectionCreationController(
store.dispatch(
CollectionCreationAction.StepChanged(
SaveCollectionStep.NameCollection,
getDefaultCollectionNumber()
store.state.tabCollections.getDefaultCollectionNumber()
)
)
}
/**
* Returns the new default name recommendation for a collection
*
* Algorithm: Go through all collections, make a list of their names and keep only the default ones.
* Then get the numbers from all these default names, compute the maximum number and add one.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun getDefaultCollectionNumber(): Int {
return (store.state.tabCollections
.map { it.title }
.filter { it.matches(Regex("Collection\\s\\d+")) }
.map { Integer.valueOf(it.split(" ")[DEFAULT_COLLECTION_NUMBER_POSITION]) }
.max() ?: 0) + DEFAULT_INCREMENT_VALUE
}
override fun addTabToSelection(tab: Tab) {
store.dispatch(CollectionCreationAction.TabAdded(tab))
}
@ -209,14 +196,4 @@ class DefaultCollectionCreationController(
SaveCollectionStep.SelectTabs, SaveCollectionStep.RenameCollection -> null
}
}
/**
* @return the number of currently active sessions that are neither custom nor private
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun normalSessionSize(sessionManager: SessionManager): Int {
return sessionManager.sessions.filter { session ->
(!session.isCustomTabSession() && !session.private)
}.size
}
}

View File

@ -11,3 +11,12 @@ import mozilla.components.browser.session.SessionManager
*/
fun SessionManager.sessionsOfType(private: Boolean) =
sessions.asSequence().filter { it.private == private }
/**
* @return the number of currently active sessions that are neither custom nor private
*/
fun SessionManager.normalSessionSize(): Int {
return this.sessions.filter { session ->
(!session.isCustomTabSession() && !session.private)
}.size
}

View File

@ -9,6 +9,7 @@ import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import mozilla.components.feature.tab.collections.TabCollection
import org.mozilla.fenix.R
import org.mozilla.fenix.collections.DefaultCollectionCreationController
import kotlin.math.abs
/**
@ -22,3 +23,17 @@ fun TabCollection.getIconColor(context: Context): Int {
iconColors.recycle()
return color
}
/**
* Returns the new default name recommendation for a collection
*
* Algorithm: Go through all collections, make a list of their names and keep only the default ones.
* Then get the numbers from all these default names, compute the maximum number and add one.
*/
fun List<TabCollection>.getDefaultCollectionNumber(): Int {
return (this
.map { it.title }
.filter { it.matches(Regex("Collection\\s\\d+")) }
.map { Integer.valueOf(it.split(" ")[DefaultCollectionCreationController.DEFAULT_COLLECTION_NUMBER_POSITION]) }
.max() ?: 0) + DefaultCollectionCreationController.DEFAULT_INCREMENT_VALUE
}

View File

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

View File

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

View File

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

View File

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

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.view.LayoutInflater
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.tab_tray_item.view.*
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.browser.tabstray.TabsAdapter
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.support.images.loader.ImageLoader
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.metrics
class FenixTabsAdapter(
context: Context,
private val context: Context,
imageLoader: ImageLoader
) : TabsAdapter(
viewHolderProvider = { parentView ->
@ -21,11 +26,19 @@ class FenixTabsAdapter(
LayoutInflater.from(context).inflate(
R.layout.tab_tray_item,
parentView,
false),
false
),
imageLoader
)
}
) {
var tabTrayInteractor: TabTrayInteractor? = null
private val mode: TabTrayDialogFragmentState.Mode?
get() = tabTrayInteractor?.onModeRequested()
val selectedItems get() = mode?.selectedItems ?: setOf()
var onTabsUpdated: (() -> Unit)? = null
var tabCount = 0
@ -35,9 +48,53 @@ class FenixTabsAdapter(
tabCount = tabs.list.size
}
override fun onBindViewHolder(
holder: TabViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isNullOrEmpty()) {
onBindViewHolder(holder, position)
return
}
// Otherwise, item needs to be checked or unchecked
val shouldBeChecked =
mode is TabTrayDialogFragmentState.Mode.MultiSelect && selectedItems.contains(holder.tab)
holder.itemView.checkmark.isVisible = shouldBeChecked
holder.itemView.selected_mask.isVisible = shouldBeChecked
}
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
val newIndex = tabCount - position - 1
(holder as TabTrayViewHolder).updateAccessibilityRowIndex(holder.itemView, newIndex)
holder.tab?.let { tab ->
val tabIsPrivate =
context.components.core.sessionManager.findSessionById(tab.id)?.private == true
if (!tabIsPrivate) {
holder.itemView.setOnLongClickListener {
if (mode is TabTrayDialogFragmentState.Mode.Normal) {
context.metrics.track(Event.CollectionTabLongPressed)
tabTrayInteractor?.onAddSelectedTab(
tab
)
}
true
}
}
holder.itemView.setOnClickListener {
if (mode is TabTrayDialogFragmentState.Mode.MultiSelect) {
if (mode?.selectedItems?.contains(tab) == true) {
tabTrayInteractor?.onRemoveSelectedTab(tab = tab)
} else {
tabTrayInteractor?.onAddSelectedTab(tab = tab)
}
} else {
tabTrayInteractor?.onOpenTab(tab = tab)
}
}
}
}
}

View File

@ -9,10 +9,11 @@ import androidx.navigation.NavController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.session.Session
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.tabstray.Tab
import mozilla.components.feature.tabs.TabsUseCases
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.home.HomeFragment
@ -22,61 +23,81 @@ import org.mozilla.fenix.home.HomeFragment
*
* Delegated by View Interactors, handles container business logic and operates changes on it.
*/
@Suppress("TooManyFunctions")
interface TabTrayController {
fun onNewTabTapped(private: Boolean)
fun onTabTrayDismissed()
fun onShareTabsClicked(private: Boolean)
fun onSaveToCollectionClicked()
fun onSaveToCollectionClicked(selectedTabs: Set<Tab>)
fun onCloseAllTabsClicked(private: Boolean)
fun handleBackPressed(): Boolean
fun onModeRequested(): TabTrayDialogFragmentState.Mode
fun handleAddSelectedTab(tab: Tab)
fun handleRemoveSelectedTab(tab: Tab)
fun handleOpenTab(tab: Tab)
fun handleEnterMultiselect()
}
@Suppress("TooManyFunctions")
/**
* Default behavior of [TabTrayController]. Other implementations are possible.
*
* @param activity [HomeActivity] used for context and other Android interactions.
* @param navController [NavController] used for navigation.
* @param dismissTabTray callback allowing to request this entire Fragment to be dismissed.
* @param tabTrayDialogFragmentStore [TabTrayDialogFragmentStore] holding the State for all Views displayed
* in this Controller's Fragment.
* @param dismissTabTrayAndNavigateHome callback allowing showing an undo snackbar after tab deletion.
* @param selectTabUseCase [TabsUseCases.SelectTabUseCase] callback allowing for selecting a tab.
* @param registerCollectionStorageObserver callback allowing for registering the [TabCollectionStorage.Observer]
* when needed.
* @param showChooseCollectionDialog callback allowing saving a list of sessions to an existing collection.
* @param showAddNewCollectionDialog callback allowing for saving a list of sessions to a new collection.
*/
@Suppress("TooManyFunctions", "LongParameterList")
class DefaultTabTrayController(
private val activity: HomeActivity,
private val navController: NavController,
private val dismissTabTray: () -> Unit,
private val dismissTabTrayAndNavigateHome: (String) -> Unit,
private val registerCollectionStorageObserver: () -> Unit
private val registerCollectionStorageObserver: () -> Unit,
private val tabTrayDialogFragmentStore: TabTrayDialogFragmentStore,
private val selectTabUseCase: TabsUseCases.SelectTabUseCase,
private val showChooseCollectionDialog: (List<Session>) -> Unit,
private val showAddNewCollectionDialog: (List<Session>) -> Unit
) : TabTrayController {
private val tabCollectionStorage = activity.components.core.tabCollectionStorage
override fun onNewTabTapped(private: Boolean) {
val startTime = activity.components.core.engine.profiler?.getProfilerTime()
activity.browsingModeManager.mode = BrowsingMode.fromBoolean(private)
navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true))
dismissTabTray()
activity.components.core.engine.profiler?.addMarker("DefaultTabTrayController.onNewTabTapped", startTime)
activity.components.core.engine.profiler?.addMarker(
"DefaultTabTrayController.onNewTabTapped",
startTime
)
}
override fun onTabTrayDismissed() {
dismissTabTray()
}
override fun onSaveToCollectionClicked() {
val tabs = getListOfSessions(false)
val tabIds = tabs.map { it.id }.toList().toTypedArray()
val tabCollectionStorage = activity.components.core.tabCollectionStorage
val step = when {
// Show the SelectTabs fragment if there are multiple opened tabs to select which tabs
// you want to save to a collection.
tabs.size > 1 -> SaveCollectionStep.SelectTabs
// If there is an existing tab collection, show the SelectCollection fragment to save
// the selected tab to a collection of your choice.
tabCollectionStorage.cachedTabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection
// Show the NameCollection fragment to create a new collection for the selected tab.
else -> SaveCollectionStep.NameCollection
override fun onSaveToCollectionClicked(selectedTabs: Set<Tab>) {
val sessionList = selectedTabs.map {
activity.components.core.sessionManager.findSessionById(it.id) ?: return
}
if (navController.currentDestination?.id == R.id.collectionCreationFragment) return
// Only register the observer right before moving to collection creation
registerCollectionStorageObserver()
val directions = TabTrayDialogFragmentDirections.actionGlobalCollectionCreationFragment(
tabIds = tabIds,
saveCollectionStep = step,
selectedTabIds = tabIds
)
navController.navigate(directions)
when {
tabCollectionStorage.cachedTabCollections.isNotEmpty() -> {
showChooseCollectionDialog(sessionList)
}
else -> {
showAddNewCollectionDialog(sessionList)
}
}
}
override fun onShareTabsClicked(private: Boolean) {
@ -101,8 +122,37 @@ class DefaultTabTrayController(
dismissTabTrayAndNavigateHome(sessionsToClose)
}
override fun handleAddSelectedTab(tab: Tab) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.AddItemForCollection(tab))
}
override fun handleRemoveSelectedTab(tab: Tab) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.RemoveItemForCollection(tab))
}
override fun handleBackPressed(): Boolean {
return if (tabTrayDialogFragmentStore.state.mode is TabTrayDialogFragmentState.Mode.MultiSelect) {
tabTrayDialogFragmentStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
true
} else {
false
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
private fun getListOfSessions(private: Boolean): List<Session> {
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
import android.app.Dialog
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.component_tabstray.view.*
import kotlinx.android.synthetic.main.component_tabstray_fab.view.*
import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.*
import kotlinx.android.synthetic.main.fragment_tab_tray_dialog.view.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.browser.session.Session
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
@ -28,24 +36,34 @@ import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.tabs.tabstray.TabsFeature
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.BrowserFragmentDirections
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getDefaultCollectionNumber
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.normalSessionSize
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabtray.TabTrayDialogFragmentState.Mode
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
class TabTrayDialogFragment : AppCompatDialogFragment() {
class TabTrayDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
private val args by navArgs<TabTrayDialogFragmentArgs>()
private val tabsFeature = ViewBoundFeatureWrapper<TabsFeature>()
private var _tabTrayView: TabTrayView? = null
private val tabTrayView: TabTrayView
get() = _tabTrayView!!
private lateinit var tabTrayDialogStore: TabTrayDialogFragmentStore
private val snackbarAnchor: View?
get() = if (tabTrayView.fabView.new_tab_button.isVisible) tabTrayView.fabView.new_tab_button
@ -75,6 +93,14 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireContext(), this.theme) {
override fun onBackPressed() {
this@TabTrayDialogFragment.onBackPressed()
}
}
}
private val removeTabUseCase = object : TabsUseCases.RemoveTabUseCase {
override fun invoke(sessionId: String) {
requireContext().components.analytics.metrics.track(Event.ClosedExistingTab)
@ -109,7 +135,18 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false)
): View? {
tabTrayDialogStore = StoreProvider.get(this) {
TabTrayDialogFragmentStore(
TabTrayDialogFragmentState(
requireComponents.core.store.state,
if (args.enterMultiselect) Mode.MultiSelect(setOf()) else Mode.Normal
)
)
}
return inflater.inflate(R.layout.fragment_tab_tray_dialog, container, false)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -140,7 +177,11 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
navController = findNavController(),
dismissTabTray = ::dismissAllowingStateLoss,
dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome,
registerCollectionStorageObserver = ::registerCollectionStorageObserver
registerCollectionStorageObserver = ::registerCollectionStorageObserver,
tabTrayDialogFragmentStore = tabTrayDialogStore,
selectTabUseCase = selectTabUseCase,
showChooseCollectionDialog = ::showChooseCollectionDialog,
showAddNewCollectionDialog = ::showAddNewCollectionDialog
)
),
isPrivate = isPrivate,
@ -188,6 +229,10 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
}
consumeFrom(requireComponents.core.store) {
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.BrowserStateChanged(it))
}
consumeFrom(tabTrayDialogStore) {
tabTrayView.updateState(it)
}
}
@ -209,7 +254,8 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
} ?: return
// Check if this is the last tab of this session type
val isLastOpenTab = sessionManager.sessions.filter { snapshot.session.private == it.private }.size == 1
val isLastOpenTab =
sessionManager.sessions.filter { snapshot.session.private == it.private }.size == 1
if (isLastOpenTab) {
dismissTabTrayAndNavigateHome(sessionId)
@ -295,21 +341,101 @@ class TabTrayDialogFragment : AppCompatDialogFragment() {
}
}
companion object {
private const val ELEVATION = 80f
private const val FRAGMENT_TAG = "tabTrayDialogFragment"
override fun onBackPressed(): Boolean {
if (!tabTrayView.onBackPressed()) {
dismiss()
}
return true
}
fun show(fragmentManager: FragmentManager) {
// If we've killed the fragmentManager. Let's not try to show the tabs tray.
if (fragmentManager.isDestroyed) {
return
}
private fun showChooseCollectionDialog(sessionList: List<Session>) {
context?.let {
val tabCollectionStorage = it.components.core.tabCollectionStorage
val collections =
tabCollectionStorage.cachedTabCollections.map { it.title }.toTypedArray()
val customLayout =
LayoutInflater.from(it).inflate(R.layout.add_new_collection_dialog, null)
val list = customLayout.findViewById<RecyclerView>(R.id.recycler_view)
list.layoutManager = LinearLayoutManager(it)
// We want to make sure we don't accidentally show the dialog twice if
// a user somehow manages to trigger `show()` twice before we present the dialog.
if (fragmentManager.findFragmentByTag(FRAGMENT_TAG) == null) {
TabTrayDialogFragment().showNow(fragmentManager, FRAGMENT_TAG)
}
val builder = AlertDialog.Builder(it).setTitle(R.string.tab_tray_select_collection)
.setView(customLayout)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
val selectedCollection =
(list.adapter as CollectionsAdapter).getSelectedCollection()
val collection = tabCollectionStorage.cachedTabCollections[selectedCollection]
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage.addTabsToCollection(collection, sessionList)
it.metrics.track(
Event.CollectionTabsAdded(
it.components.core.sessionManager.normalSessionSize(),
sessionList.size
)
)
launch(Main) {
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.dismiss()
}
}
}.setNegativeButton(android.R.string.cancel) { dialog, _ ->
tabTrayDialogStore.dispatch(TabTrayDialogFragmentAction.ExitMultiSelectMode)
dialog.cancel()
}
val dialog = builder.create()
val adapter =
CollectionsAdapter(arrayOf(it.getString(R.string.tab_tray_add_new_collection)) + collections) {
dialog.dismiss()
showAddNewCollectionDialog(sessionList)
}
list.adapter = adapter
dialog.show()
}
}
private fun showAddNewCollectionDialog(sessionList: List<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
import mozilla.components.concept.tabstray.Tab
@Suppress("TooManyFunctions")
interface TabTrayInteractor {
/**
* Called when user clicks the new tab button.
*/
fun onNewTabTapped(private: Boolean)
/**
* Called when tab tray should be dismissed.
*/
fun onTabTrayDismissed()
/**
* Called when user clicks the share tabs button.
*/
fun onShareTabsClicked(private: Boolean)
fun onSaveToCollectionClicked()
/**
* Called when user clicks button to save selected tabs to a collection.
*/
fun onSaveToCollectionClicked(selectedTabs: Set<Tab>)
/**
* Called when user clicks the close all tabs button.
*/
fun onCloseAllTabsClicked(private: Boolean)
/**
* Called when the physical back button is clicked.
*/
fun onBackPressed(): Boolean
/**
* Called when a requester needs to know the current mode of the tab tray.
*/
fun onModeRequested(): TabTrayDialogFragmentState.Mode
/**
* Called when a tab should be opened in the browser.
*/
fun onOpenTab(tab: Tab)
/**
* Called when a tab should be selected in multiselect mode.
*/
fun onAddSelectedTab(tab: Tab)
/**
* Called when a tab should be unselected in multiselect mode.
*/
fun onRemoveSelectedTab(tab: Tab)
/**
* Called when multiselect mode should be entered with no tabs selected.
*/
fun onEnterMultiselect()
}
/**
* Interactor for the tab tray fragment.
*/
@Suppress("TooManyFunctions")
class TabTrayFragmentInteractor(private val controller: TabTrayController) : TabTrayInteractor {
override fun onNewTabTapped(private: Boolean) {
controller.onNewTabTapped(private)
@ -28,11 +81,35 @@ class TabTrayFragmentInteractor(private val controller: TabTrayController) : Tab
controller.onShareTabsClicked(private)
}
override fun onSaveToCollectionClicked() {
controller.onSaveToCollectionClicked()
override fun onSaveToCollectionClicked(selectedTabs: Set<Tab>) {
controller.onSaveToCollectionClicked(selectedTabs)
}
override fun onCloseAllTabsClicked(private: Boolean) {
controller.onCloseAllTabsClicked(private)
}
override fun onBackPressed(): Boolean {
return controller.handleBackPressed()
}
override fun onModeRequested(): TabTrayDialogFragmentState.Mode {
return controller.onModeRequested()
}
override fun onAddSelectedTab(tab: Tab) {
controller.handleAddSelectedTab(tab)
}
override fun onRemoveSelectedTab(tab: Tab) {
controller.handleRemoveSelectedTab(tab)
}
override fun onOpenTab(tab: Tab) {
controller.handleOpenTab(tab)
}
override fun onEnterMultiselect() {
controller.handleEnterMultiselect()
}
}

View File

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

View File

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

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
- 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/. -->
<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:mozac="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/tab_wrapper"
android:layout_height="match_parent"
android:layout_width="match_parent"
style="@style/BottomSheetModal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/foundation_normal_theme"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
@ -20,81 +19,141 @@
android:layout_height="3dp"
android:layout_marginTop="8dp"
android:background="@color/secondary_text_normal_theme"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.1"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.1" />
<TextView
android:id="@+id/tab_tray_empty_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center_horizontal"
android:paddingTop="80dp"
android:text="@string/no_open_tabs_description"
android:focusable="true"
android:focusableInTouchMode="true"
android:textColor="?secondaryText"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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
android:id="@+id/tab_layout"
android:layout_width="0dp"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@color/foundation_normal_theme"
app:layout_constraintStart_toStartOf="parent"
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">
app:layout_constraintTop_toBottomOf="@+id/handle">
<com.google.android.material.tabs.TabItem
android:id="@+id/default_tab_item"
<ImageButton
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_height="match_parent"
android:contentDescription="@string/tab_header_label"
android:layout="@layout/tabs_tray_tab_counter"
app:tabIconTint="@color/tab_icon" />
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="@color/contrast_text_normal_theme"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/collect_multi_select"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@+id/exit_multi_select"
app:layout_constraintTop_toTopOf="parent"
tools:text="3 selected" />
<com.google.android.material.tabs.TabItem
android:id="@+id/private_tab_item"
<TextView
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_height="match_parent"
android:contentDescription="@string/tabs_header_private_tabs_title"
android:icon="@drawable/ic_private_browsing" />
android:layout_height="80dp"
android:background="@color/foundation_normal_theme"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.5"
app:tabGravity="fill"
app:tabIconTint="@color/tab_icon"
app:tabIndicatorColor="@color/accent_normal_theme"
app:tabRippleColor="@android:color/transparent">
</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
android:id="@+id/tab_tray_new_tab"
android:layout_width="48dp"
android:layout_height="48dp"
android:visibility="gone"
android:background="?android:attr/selectableItemBackgroundBorderless"
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" />
<com.google.android.material.tabs.TabItem
android:id="@+id/private_tab_item"
android:layout_width="0dp"
android:layout_height="match_parent"
android:contentDescription="@string/tabs_header_private_tabs_title"
android:icon="@drawable/ic_private_browsing" />
<ImageButton
android:id="@+id/tab_tray_overflow"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/open_tabs_menu"
app:srcCompat="@drawable/ic_menu_tab_tray"
android:layout_marginEnd="0dp"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tab_layout"
app:layout_constraintBottom_toBottomOf="@id/tab_layout" />
</com.google.android.material.tabs.TabLayout>
<ImageButton
android:id="@+id/tab_tray_new_tab"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_tab"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/tab_layout"
app:layout_constraintEnd_toStartOf="@id/tab_tray_overflow"
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
android:id="@+id/divider"
@ -103,14 +162,14 @@
android:background="@color/tab_tray_item_divider_normal_theme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tab_layout" />
app:layout_constraintTop_toBottomOf="@+id/topBar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tabsTray"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingBottom="140dp"
android:clipToPadding="false"
android:paddingBottom="140dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="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:layout_width="match_parent"
android:layout_height="88dp"
android:focusable="true">
android:clickable="true"
android:focusable="true"
android:foreground="?android:selectableItemBackground">
<ImageButton
android:id="@+id/play_pause_button"
@ -36,12 +38,12 @@
<ImageView
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_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
android:id="@+id/mozac_browser_tabstray_thumbnail"
@ -49,6 +51,26 @@
android:layout_height="match_parent"
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>
<TextView
@ -88,10 +110,10 @@
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/close_tab"
android:tint="@color/tab_tray_item_text_normal_theme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
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>

View File

@ -109,7 +109,12 @@
<dialog
android:id="@+id/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
android:id="@+id/homeFragment"
@ -374,13 +379,13 @@
android:id="@+id/syncedTabsFragment"
android:name="org.mozilla.fenix.sync.SyncedTabsFragment"
android:label="@string/synced_tabs"
tools:layout="@layout/fragment_synced_tabs"/>
tools:layout="@layout/fragment_synced_tabs" />
<fragment
android:id="@+id/loginExceptionsFragment"
android:name="org.mozilla.fenix.loginexceptions.LoginExceptionsFragment"
android:label="@string/preferences_passwords_exceptions"
tools:layout="@layout/fragment_exceptions"/>
tools:layout="@layout/fragment_exceptions" />
<fragment
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_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_selected_mask_normal_theme">@color/tab_tray_selected_mask_dark_theme</color>
<!--Top site colors -->
<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_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_selected_mask_light_theme">@color/violet_70_12a</color>
<!-- Dark theme color palette -->
<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_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_selected_mask_dark_theme">@color/violet_50_32a</color>
<!-- Private theme color palette -->
<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_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_selected_mask_normal_theme">@color/tab_tray_selected_mask_light_theme</color>
<!-- Bookmark buttons -->
<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>
<!-- 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>
<!-- 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) -->
<string name="about_content">%1$s is produced by Mozilla.</string>
@ -498,6 +520,8 @@
<string name="remove_top_site">Remove</string>
<!-- Postfix for private WebApp titles, placeholder is replaced with app name -->
<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 -->
<!-- Text for the button to clear all history -->

View File

@ -100,6 +100,10 @@
<style name="NormalTheme" parent="NormalThemeBase" />
<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="android:colorBackground">?above</item>
<item name="colorAccent">?accent</item>

View File

@ -210,41 +210,6 @@ class DefaultCollectionCreationControllerTest {
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
fun `WHEN adding a new collection THEN dispatch NameCollection step changed`() {
controller.addNewCollection()
@ -275,27 +240,6 @@ class DefaultCollectionCreationControllerTest {
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(
sessionId: String? = null,
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 {
val sessionManager: SessionManager = mockk()
every { sessionManager.sessions } returns sessions
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))
}
@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 {
val collection: TabCollection = mockk()
every { collection.id } returns id

View File

@ -336,7 +336,7 @@ class DefaultSessionControlControllerTest {
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_collectionCreationFragment },
match<NavDirections> { it.actionId == R.id.action_global_tabTrayDialogFragment },
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.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.tabstray.Tab
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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.sessionsOfType
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTabTrayControllerTest {
private val activity: HomeActivity = mockk(relaxed = true)
private val navController: NavController = mockk()
private val sessionManager: SessionManager = mockk(relaxed = true)
private val dismissTabTray: (() -> 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 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 tabCollection: TabCollection = mockk()
private val cachedTabCollections: List<TabCollection> = listOf(tabCollection)
private val currentDestination: NavDestination = mockk(relaxed = true)
private val tabTrayFragmentStore: TabTrayDialogFragmentStore = mockk(relaxed = true)
private val selectTabUseCase: TabsUseCases.SelectTabUseCase = mockk(relaxed = true)
private lateinit var controller: DefaultTabTrayController
@ -59,28 +64,34 @@ class DefaultTabTrayControllerTest {
@Before
fun setUp() {
mockkStatic("org.mozilla.fenix.ext.SessionManagerKt")
every { activity.components.core.sessionManager } returns sessionManager
every { activity.components.core.tabCollectionStorage } returns tabCollectionStorage
every { activity.components.core.engine.profiler } returns mockk(relaxed = true)
every { sessionManager.sessionsOfType(private = true) } returns listOf(session).asSequence()
every { sessionManager.sessionsOfType(private = false) } returns listOf(nonPrivateSession).asSequence()
every { sessionManager.createSessionSnapshot(any()) } returns SessionManager.Snapshot.Item(
session
)
every { sessionManager.findSessionById("1234") } returns session
every { sessionManager.remove(any()) } just Runs
every { tabCollectionStorage.cachedTabCollections } returns cachedTabCollections
every { sessionManager.selectedSession } returns nonPrivateSession
every { navController.navigate(any<NavDirections>()) } just Runs
every { navController.currentDestination } returns currentDestination
every { currentDestination.id } returns R.id.browserFragment
every { tabCollection.title } returns "Collection title"
controller = DefaultTabTrayController(
activity = activity,
navController = navController,
dismissTabTray = dismissTabTray,
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
fun onShareTabsClicked() {
val navDirectionsSlot = slot<NavDirections>()
@ -161,4 +154,73 @@ class DefaultTabTrayControllerTest {
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.verify
import mozilla.components.concept.tabstray.Tab
import org.junit.Test
class TabTrayFragmentInteractorTest {
@ -38,8 +39,9 @@ class TabTrayFragmentInteractorTest {
@Test
fun onSaveToCollectionClicked() {
interactor.onSaveToCollectionClicked()
verify { controller.onSaveToCollectionClicked() }
val tab = Tab("1234", "mozilla.org")
interactor.onSaveToCollectionClicked(setOf(tab))
verify { controller.onSaveToCollectionClicked(setOf(tab)) }
}
@Test
@ -50,4 +52,43 @@ class TabTrayFragmentInteractorTest {
interactor.onCloseAllTabsClicked(private = 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)
}
private fun createViewHolder(getSelectedTabId: () -> String? = { null }) = TabTrayViewHolder(
private fun createViewHolder() = TabTrayViewHolder(
view,
imageLoader = imageLoader,
store = store,
metrics = metrics,
getSelectedTabId = getSelectedTabId
metrics = metrics
)
}