Commit e4fa71fd authored by ZianeA's avatar ZianeA Committed by Jonathan Almeida
Browse files

For #[15083]: Add multi select to recently closed tabs

parent 7d5582a5
......@@ -9,28 +9,42 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import mozilla.components.browser.state.state.recover.RecoverableTab
import org.mozilla.fenix.selection.SelectionHolder
class RecentlyClosedAdapter(
private val interactor: RecentlyClosedFragmentInteractor
) : ListAdapter<RecoverableTab, RecentlyClosedItemViewHolder>(DiffCallback) {
) : ListAdapter<RecoverableTab, RecentlyClosedItemViewHolder>(DiffCallback),
SelectionHolder<RecoverableTab> {
private var selectedTabs: Set<RecoverableTab> = emptySet()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecentlyClosedItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(RecentlyClosedItemViewHolder.LAYOUT_ID, parent, false)
return RecentlyClosedItemViewHolder(view, interactor)
return RecentlyClosedItemViewHolder(view, interactor, this)
}
override fun onBindViewHolder(holder: RecentlyClosedItemViewHolder, position: Int) {
holder.bind(getItem(position))
}
override val selectedItems: Set<RecoverableTab>
get() = selectedTabs
fun updateData(tabs: List<RecoverableTab>, selectedTabs: Set<RecoverableTab>) {
this.selectedTabs = selectedTabs
notifyItemRangeChanged(0, tabs.size)
submitList(tabs)
}
private object DiffCallback : DiffUtil.ItemCallback<RecoverableTab>() {
override fun areItemsTheSame(oldItem: RecoverableTab, newItem: RecoverableTab) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: RecoverableTab, newItem: RecoverableTab) =
oldItem.id == newItem.id
oldItem == newItem
}
}
......@@ -21,18 +21,27 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
@Suppress("TooManyFunctions")
interface RecentlyClosedController {
fun handleOpen(item: RecoverableTab, mode: BrowsingMode? = null)
fun handleDeleteOne(tab: RecoverableTab)
fun handleOpen(tab: RecoverableTab, mode: BrowsingMode? = null)
fun handleOpen(tabs: Set<RecoverableTab>, mode: BrowsingMode? = null)
fun handleDelete(tab: RecoverableTab)
fun handleDelete(tabs: Set<RecoverableTab>)
fun handleCopyUrl(item: RecoverableTab)
fun handleShare(item: RecoverableTab)
fun handleShare(tab: RecoverableTab)
fun handleShare(tabs: Set<RecoverableTab>)
fun handleNavigateToHistory()
fun handleRestore(item: RecoverableTab)
fun handleSelect(tab: RecoverableTab)
fun handleDeselect(tab: RecoverableTab)
fun handleBackPressed(): Boolean
}
@Suppress("TooManyFunctions")
class DefaultRecentlyClosedController(
private val navController: NavController,
private val store: BrowserStore,
private val browserStore: BrowserStore,
private val recentlyClosedStore: RecentlyClosedFragmentStore,
private val tabsUseCases: TabsUseCases,
private val resources: Resources,
private val snackbar: FenixSnackbar,
......@@ -40,12 +49,30 @@ class DefaultRecentlyClosedController(
private val activity: HomeActivity,
private val openToBrowser: (item: RecoverableTab, mode: BrowsingMode?) -> Unit
) : RecentlyClosedController {
override fun handleOpen(item: RecoverableTab, mode: BrowsingMode?) {
openToBrowser(item, mode)
override fun handleOpen(tab: RecoverableTab, mode: BrowsingMode?) {
openToBrowser(tab, mode)
}
override fun handleDeleteOne(tab: RecoverableTab) {
store.dispatch(RecentlyClosedAction.RemoveClosedTabAction(tab))
override fun handleOpen(tabs: Set<RecoverableTab>, mode: BrowsingMode?) {
recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.DeselectAll)
tabs.forEach { tab -> handleOpen(tab, mode) }
}
override fun handleSelect(tab: RecoverableTab) {
recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Select(tab))
}
override fun handleDeselect(tab: RecoverableTab) {
recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Deselect(tab))
}
override fun handleDelete(tab: RecoverableTab) {
browserStore.dispatch(RecentlyClosedAction.RemoveClosedTabAction(tab))
}
override fun handleDelete(tabs: Set<RecoverableTab>) {
recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.DeselectAll)
tabs.forEach { tab -> handleDelete(tab) }
}
override fun handleNavigateToHistory() {
......@@ -64,10 +91,13 @@ class DefaultRecentlyClosedController(
}
}
override fun handleShare(item: RecoverableTab) {
override fun handleShare(tab: RecoverableTab) = handleShare(setOf(tab))
override fun handleShare(tabs: Set<RecoverableTab>) {
val shareData = tabs.map { ShareData(url = it.url, title = it.title) }
navController.navigateBlockingForAsyncNavGraph(
RecentlyClosedFragmentDirections.actionGlobalShareFragment(
data = arrayOf(ShareData(url = item.url, title = item.title))
data = shareData.toTypedArray()
)
)
}
......@@ -75,7 +105,7 @@ class DefaultRecentlyClosedController(
override fun handleRestore(item: RecoverableTab) {
tabsUseCases.restore(item)
store.dispatch(
browserStore.dispatch(
RecentlyClosedAction.RemoveClosedTabAction(item)
)
......@@ -83,4 +113,13 @@ class DefaultRecentlyClosedController(
from = BrowserDirection.FromRecentlyClosed
)
}
override fun handleBackPressed(): Boolean {
return if (recentlyClosedStore.state.selectedTabs.isNotEmpty()) {
recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.DeselectAll)
true
} else {
false
}
}
}
......@@ -7,6 +7,7 @@ package org.mozilla.fenix.library.recentlyclosed
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.SpannableString
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
......@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.state.recover.RecoverableTab
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
......@@ -30,17 +32,19 @@ import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.library.LibraryPageFragment
@Suppress("TooManyFunctions")
class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>() {
class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>(), UserInteractionHandler {
private lateinit var recentlyClosedFragmentStore: RecentlyClosedFragmentStore
private var _recentlyClosedFragmentView: RecentlyClosedFragmentView? = null
protected val recentlyClosedFragmentView: RecentlyClosedFragmentView
get() = _recentlyClosedFragmentView!!
private lateinit var recentlyClosedInteractor: RecentlyClosedFragmentInteractor
private lateinit var recentlyClosedController: RecentlyClosedController
override fun onResume() {
super.onResume()
......@@ -48,15 +52,43 @@ class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>() {
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.library_menu, menu)
if (recentlyClosedFragmentStore.state.selectedTabs.isNotEmpty()) {
inflater.inflate(R.menu.history_select_multi, menu)
menu.findItem(R.id.delete_history_multi_select)?.let { deleteItem ->
deleteItem.title = SpannableString(deleteItem.title)
.apply { setTextColor(requireContext(), R.attr.destructive) }
}
} else {
inflater.inflate(R.menu.library_menu, menu)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.close_history -> {
close()
true
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val selectedTabs = recentlyClosedFragmentStore.state.selectedTabs
return when (item.itemId) {
R.id.close_history -> {
close()
true
}
R.id.share_history_multi_select -> {
recentlyClosedController.handleShare(selectedTabs)
true
}
R.id.delete_history_multi_select -> {
recentlyClosedController.handleDelete(selectedTabs)
true
}
R.id.open_history_in_new_tabs_multi_select -> {
recentlyClosedController.handleOpen(selectedTabs, BrowsingMode.Normal)
true
}
R.id.open_history_in_private_tabs_multi_select -> {
recentlyClosedController.handleOpen(selectedTabs, BrowsingMode.Private)
true
}
else -> super.onOptionsItemSelected(item)
}
else -> super.onOptionsItemSelected(item)
}
override fun onCreate(savedInstanceState: Bundle?) {
......@@ -73,25 +105,26 @@ class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>() {
recentlyClosedFragmentStore = StoreProvider.get(this) {
RecentlyClosedFragmentStore(
RecentlyClosedFragmentState(
items = listOf()
items = listOf(),
selectedTabs = emptySet()
)
)
}
recentlyClosedInteractor = RecentlyClosedFragmentInteractor(
recentlyClosedController = DefaultRecentlyClosedController(
navController = findNavController(),
store = requireComponents.core.store,
activity = activity as HomeActivity,
tabsUseCases = requireComponents.useCases.tabsUseCases,
resources = requireContext().resources,
snackbar = FenixSnackbar.make(
view = requireActivity().getRootView()!!,
isDisplayedWithBrowserToolbar = true
),
clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager,
openToBrowser = ::openItem
)
recentlyClosedController = DefaultRecentlyClosedController(
navController = findNavController(),
browserStore = requireComponents.core.store,
recentlyClosedStore = recentlyClosedFragmentStore,
activity = activity as HomeActivity,
tabsUseCases = requireComponents.useCases.tabsUseCases,
resources = requireContext().resources,
snackbar = FenixSnackbar.make(
view = requireActivity().getRootView()!!,
isDisplayedWithBrowserToolbar = true
),
clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager,
openToBrowser = ::openItem
)
recentlyClosedInteractor = RecentlyClosedFragmentInteractor(recentlyClosedController)
_recentlyClosedFragmentView = RecentlyClosedFragmentView(
view.recentlyClosedLayout,
recentlyClosedInteractor
......@@ -116,8 +149,9 @@ class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>() {
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(recentlyClosedFragmentStore) {
recentlyClosedFragmentView.update(it.items)
consumeFrom(recentlyClosedFragmentStore) { state ->
recentlyClosedFragmentView.update(state)
activity?.invalidateOptionsMenu()
}
requireComponents.core.store.flowScoped(viewLifecycleOwner) { flow ->
......@@ -132,4 +166,8 @@ class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>() {
}
override val selectedItems: Set<RecoverableTab> = setOf()
override fun onBackPressed(): Boolean {
return recentlyClosedController.handleBackPressed()
}
}
......@@ -34,11 +34,23 @@ class RecentlyClosedFragmentInteractor(
recentlyClosedController.handleOpen(item, BrowsingMode.Private)
}
override fun onDeleteOne(tab: RecoverableTab) {
recentlyClosedController.handleDeleteOne(tab)
override fun onDelete(tab: RecoverableTab) {
recentlyClosedController.handleDelete(tab)
}
override fun onNavigateToHistory() {
recentlyClosedController.handleNavigateToHistory()
}
override fun open(item: RecoverableTab) {
recentlyClosedController.handleRestore(item)
}
override fun select(item: RecoverableTab) {
recentlyClosedController.handleSelect(item)
}
override fun deselect(item: RecoverableTab) {
recentlyClosedController.handleDeselect(item)
}
}
......@@ -24,13 +24,19 @@ class RecentlyClosedFragmentStore(initialState: RecentlyClosedFragmentState) :
*/
sealed class RecentlyClosedFragmentAction : Action {
data class Change(val list: List<RecoverableTab>) : RecentlyClosedFragmentAction()
data class Select(val tab: RecoverableTab) : RecentlyClosedFragmentAction()
data class Deselect(val tab: RecoverableTab) : RecentlyClosedFragmentAction()
object DeselectAll : RecentlyClosedFragmentAction()
}
/**
* The state for the Recently Closed Screen
* @property items List of recently closed tabs to display
*/
data class RecentlyClosedFragmentState(val items: List<RecoverableTab> = emptyList()) : State
data class RecentlyClosedFragmentState(
val items: List<RecoverableTab> = emptyList(),
val selectedTabs: Set<RecoverableTab>
) : State
/**
* The RecentlyClosedFragmentState Reducer.
......@@ -41,5 +47,12 @@ private fun recentlyClosedStateReducer(
): RecentlyClosedFragmentState {
return when (action) {
is RecentlyClosedFragmentAction.Change -> state.copy(items = action.list)
is RecentlyClosedFragmentAction.Select -> {
state.copy(selectedTabs = state.selectedTabs + action.tab)
}
is RecentlyClosedFragmentAction.Deselect -> {
state.copy(selectedTabs = state.selectedTabs - action.tab)
}
RecentlyClosedFragmentAction.DeselectAll -> state.copy(selectedTabs = emptySet())
}
}
......@@ -5,17 +5,19 @@
package org.mozilla.fenix.library.recentlyclosed
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import androidx.recyclerview.widget.SimpleItemAnimator
import kotlinx.android.synthetic.main.component_recently_closed.*
import mozilla.components.browser.state.state.recover.RecoverableTab
import org.mozilla.fenix.R
import org.mozilla.fenix.library.LibraryPageView
import org.mozilla.fenix.selection.SelectionInteractor
interface RecentlyClosedInteractor {
interface RecentlyClosedInteractor : SelectionInteractor<RecoverableTab> {
/**
* Called when an item is tapped to restore it.
*
......@@ -57,11 +59,11 @@ interface RecentlyClosedInteractor {
fun onOpenInPrivateTab(item: RecoverableTab)
/**
* Deletes one recently closed tab item.
* Called when recently closed tab is selected for deletion.
*
* @param tab the recently closed tab item to delete.
* @param tab the recently closed tab to delete.
*/
fun onDeleteOne(tab: RecoverableTab)
fun onDelete(tab: RecoverableTab)
}
/**
......@@ -70,11 +72,10 @@ interface RecentlyClosedInteractor {
class RecentlyClosedFragmentView(
container: ViewGroup,
private val interactor: RecentlyClosedFragmentInteractor
) : LayoutContainer {
) : LibraryPageView(container) {
override val containerView: ConstraintLayout = LayoutInflater.from(container.context)
val view: View = LayoutInflater.from(container.context)
.inflate(R.layout.component_recently_closed, container, true)
.findViewById(R.id.recently_closed_wrapper)
private val recentlyClosedAdapter: RecentlyClosedAdapter = RecentlyClosedAdapter(interactor)
......@@ -82,6 +83,7 @@ class RecentlyClosedFragmentView(
recently_closed_list.apply {
layoutManager = LinearLayoutManager(containerView.context)
adapter = recentlyClosedAdapter
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
view_more_history.apply {
......@@ -102,9 +104,20 @@ class RecentlyClosedFragmentView(
}
}
fun update(items: List<RecoverableTab>) {
recently_closed_empty_view.isVisible = items.isEmpty()
recently_closed_list.isVisible = items.isNotEmpty()
recentlyClosedAdapter.submitList(items)
fun update(state: RecentlyClosedFragmentState) {
state.apply {
recently_closed_empty_view.isVisible = items.isEmpty()
recently_closed_list.isVisible = items.isNotEmpty()
recentlyClosedAdapter.updateData(items, selectedTabs)
if (selectedTabs.isEmpty()) {
setUiForNormalMode(context.getString(R.string.library_recently_closed_tabs))
} else {
setUiForSelectingMode(
context.getString(R.string.history_multi_select_title, selectedTabs.size)
)
}
}
}
}
......@@ -7,14 +7,19 @@ package org.mozilla.fenix.library.recentlyclosed
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.history_list_item.view.*
import kotlinx.android.synthetic.main.library_site_item.view.*
import mozilla.components.browser.state.state.recover.RecoverableTab
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.utils.Do
class RecentlyClosedItemViewHolder(
view: View,
private val recentlyClosedFragmentInteractor: RecentlyClosedFragmentInteractor
private val recentlyClosedFragmentInteractor: RecentlyClosedFragmentInteractor,
private val selectionHolder: SelectionHolder<RecoverableTab>
) : RecyclerView.ViewHolder(view) {
private var item: RecoverableTab? = null
......@@ -30,12 +35,17 @@ class RecentlyClosedItemViewHolder(
if (item.title.isNotEmpty()) item.title else item.url
itemView.history_layout.urlView.text = item.url
itemView.history_layout.setSelectionInteractor(item, selectionHolder, recentlyClosedFragmentInteractor)
itemView.history_layout.changeSelected(item in selectionHolder.selectedItems)
if (this.item?.url != item.url) {
itemView.history_layout.loadFavicon(item.url)
}
itemView.setOnClickListener {
recentlyClosedFragmentInteractor.restore(item)
if (selectionHolder.selectedItems.isEmpty()) {
itemView.overflow_menu.showAndEnable()
} else {
itemView.overflow_menu.hideAndDisable()
}
this.item = item
......@@ -53,9 +63,7 @@ class RecentlyClosedItemViewHolder(
HistoryItemMenu.Item.OpenInPrivateTab -> recentlyClosedFragmentInteractor.onOpenInPrivateTab(
item
)
HistoryItemMenu.Item.Delete -> recentlyClosedFragmentInteractor.onDeleteOne(
item
)
HistoryItemMenu.Item.Delete -> recentlyClosedFragmentInteractor.onDelete(item)
}
}
......
......@@ -18,7 +18,8 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recently_closed_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_more_history"
tools:listitem="@layout/history_list_item" />
......
......@@ -32,6 +32,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.ext.directionsEq
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.optionsEq
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
......@@ -46,13 +47,15 @@ class DefaultRecentlyClosedControllerTest {
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val openToBrowser: (RecoverableTab, BrowsingMode?) -> Unit = mockk(relaxed = true)
private val activity: HomeActivity = mockk(relaxed = true)
private val store: BrowserStore = mockk(relaxed = true)
private val browserStore: BrowserStore = mockk(relaxed = true)
private val recentlyClosedStore: RecentlyClosedFragmentStore = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
val mockedTab: RecoverableTab = mockk(relaxed = true)
private val controller = DefaultRecentlyClosedController(
navController,
store,
browserStore,
recentlyClosedStore,
tabsUseCases,
resources,
snackbar,
......@@ -89,13 +92,62 @@ class DefaultRecentlyClosedControllerTest {
}
@Test
fun handleDeleteOne() {
fun `open multiple tabs`() {
val tabs = createFakeTabList(2)
controller.handleOpen(tabs.toSet(), BrowsingMode.Normal)
verify {
openToBrowser(tabs[0], BrowsingMode.Normal)
openToBrowser(tabs[1], BrowsingMode.Normal)
}
controller.handleOpen(tabs.toSet(), BrowsingMode.Private)
verify {
openToBrowser(tabs[0], BrowsingMode.Private)
openToBrowser(tabs[1], BrowsingMode.Private)
}
}
@Test
fun `handle select tab`() {
val selectedTab = createFakeTab()
controller.handleSelect(selectedTab)
verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Select(selectedTab)) }
}
@Test
fun `handle deselect tab`() {
val deselectedTab = createFakeTab()
controller.handleDeselect(deselectedTab)
verify { recentlyClosedStore.dispatch(RecentlyClosedFragmentAction.Deselect(deselectedTab)) }
}