pull/600/head
Kate Glazko 4 years ago committed by kglazko
parent 5344dceead
commit 88375a2294

@ -6,6 +6,7 @@ package org.mozilla.fenix.library.downloads
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.SelectionHolder
import org.mozilla.fenix.library.downloads.viewholders.DownloadsListItemViewHolder import org.mozilla.fenix.library.downloads.viewholders.DownloadsListItemViewHolder
@ -16,6 +17,7 @@ class DownloadAdapter(
private var downloads: List<DownloadItem> = listOf() private var downloads: List<DownloadItem> = listOf()
private var mode: DownloadFragmentState.Mode = DownloadFragmentState.Mode.Normal private var mode: DownloadFragmentState.Mode = DownloadFragmentState.Mode.Normal
override val selectedItems get() = mode.selectedItems override val selectedItems get() = mode.selectedItems
var pendingDeletionIds = emptySet<String>()
override fun getItemCount(): Int = downloads.size override fun getItemCount(): Int = downloads.size
override fun getItemViewType(position: Int): Int = DownloadsListItemViewHolder.LAYOUT_ID override fun getItemViewType(position: Int): Int = DownloadsListItemViewHolder.LAYOUT_ID
@ -27,14 +29,38 @@ class DownloadAdapter(
fun updateMode(mode: DownloadFragmentState.Mode) { fun updateMode(mode: DownloadFragmentState.Mode) {
this.mode = mode this.mode = mode
// Update the delete button alpha that the first item holds
if (itemCount > 0) notifyItemChanged(0)
} }
override fun onBindViewHolder(holder: DownloadsListItemViewHolder, position: Int) { override fun onBindViewHolder(holder: DownloadsListItemViewHolder, position: Int) {
holder.bind(downloads[position]) val current = downloads[position]
val isPendingDeletion = pendingDeletionIds.contains(current.id)
holder.bind(downloads[position], position == 0, mode, isPendingDeletion)
} }
fun updateDownloads(downloads: List<DownloadItem>) { fun updateDownloads(downloads: List<DownloadItem>) {
this.downloads = downloads this.downloads = downloads
notifyDataSetChanged() notifyDataSetChanged()
} }
fun updatePendingDeletionIds(pendingDeletionIds: Set<String>) {
this.pendingDeletionIds = pendingDeletionIds
}
companion object {
private val downloadDiffCallback = object : DiffUtil.ItemCallback<DownloadItem>() {
override fun areItemsTheSame(oldItem: DownloadItem, newItem: DownloadItem): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DownloadItem, newItem: DownloadItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any? {
return newItem
}
}
}
} }

@ -8,17 +8,33 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
interface DownloadController { interface DownloadController {
fun handleOpen(item: DownloadItem, mode: BrowsingMode? = null) fun handleOpen(item: DownloadItem, mode: BrowsingMode? = null)
fun handleSelect(item: DownloadItem)
fun handleDeselect(item: DownloadItem)
fun handleBackPressed(): Boolean fun handleBackPressed(): Boolean
fun handleModeSwitched()
fun handleDeleteSome(items: Set<DownloadItem>)
fun handleDeleteAll()
} }
class DefaultDownloadController( class DefaultDownloadController(
private val store: DownloadFragmentStore, private val store: DownloadFragmentStore,
private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit,
private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit,
private val deleteDownloadItems: (Set<DownloadItem>) -> Unit
) : DownloadController { ) : DownloadController {
override fun handleOpen(item: DownloadItem, mode: BrowsingMode?) { override fun handleOpen(item: DownloadItem, mode: BrowsingMode?) {
openToFileManager(item, mode) openToFileManager(item, mode)
} }
override fun handleSelect(item: DownloadItem) {
store.dispatch(DownloadFragmentAction.AddItemForRemoval(item))
}
override fun handleDeselect(item: DownloadItem) {
store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(item))
}
override fun handleBackPressed(): Boolean { override fun handleBackPressed(): Boolean {
return if (store.state.mode is DownloadFragmentState.Mode.Editing) { return if (store.state.mode is DownloadFragmentState.Mode.Editing) {
store.dispatch(DownloadFragmentAction.ExitEditMode) store.dispatch(DownloadFragmentAction.ExitEditMode)
@ -27,4 +43,16 @@ class DefaultDownloadController(
false false
} }
} }
override fun handleModeSwitched() {
invalidateOptionsMenu.invoke()
}
override fun handleDeleteAll() {
displayDeleteAll.invoke()
}
override fun handleDeleteSome(items: Set<DownloadItem>) {
deleteDownloadItems.invoke(items)
}
} }

@ -4,33 +4,49 @@
package org.mozilla.fenix.library.downloads package org.mozilla.fenix.library.downloads
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import kotlinx.android.synthetic.main.fragment_downloads.view.* import kotlinx.android.synthetic.main.fragment_downloads.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.BrowserState
import kotlinx.coroutines.launch
import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.feature.downloads.AbstractFetchDownloadService import mozilla.components.feature.downloads.AbstractFetchDownloadService
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.addons.showSnackBar
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.filterNotExistsOnDisk import org.mozilla.fenix.ext.filterNotExistsOnDisk
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass") @SuppressWarnings("TooManyFunctions", "LargeClass")
class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHandler { class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHandler {
private lateinit var downloadStore: DownloadFragmentStore private lateinit var downloadStore: DownloadFragmentStore
private lateinit var downloadView: DownloadView private lateinit var downloadView: DownloadView
private lateinit var downloadInteractor: DownloadInteractor private lateinit var downloadInteractor: DownloadInteractor
private var undoScope: CoroutineScope? = null
private var pendingDownloadDeletionJob: (suspend () -> Unit)? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -45,14 +61,18 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
DownloadFragmentStore( DownloadFragmentStore(
DownloadFragmentState( DownloadFragmentState(
items = items, items = items,
mode = DownloadFragmentState.Mode.Normal mode = DownloadFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(),
isDeletingItems = false
) )
) )
} }
val downloadController: DownloadController = DefaultDownloadController( val downloadController: DownloadController = DefaultDownloadController(
downloadStore, downloadStore,
::openItem ::openItem,
::displayDeleteAll,
::invalidateOptionsMenu,
::deleteDownloadItems
) )
downloadInteractor = DownloadInteractor( downloadInteractor = DownloadInteractor(
downloadController downloadController
@ -82,12 +102,55 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
override val selectedItems get() = downloadStore.state.mode.selectedItems override val selectedItems get() = downloadStore.state.mode.selectedItems
private fun invalidateOptionsMenu() {
activity?.invalidateOptionsMenu()
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
requireComponents.analytics.metrics.track(Event.HistoryOpened) private fun displayDeleteAll() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.download_delete_all_dialog)
setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
// Use fragment's lifecycle; the view may be gone by the time dialog is interacted with.
lifecycleScope.launch(IO) {
context.let {
it.components.useCases.downloadUseCases.removeAllDownloads()
}
updatePendingDownloadToDelete(downloadStore.state.items.toSet())
launch(Dispatchers.Main) {
showSnackBar(
requireView(),
getString(R.string.download_delete_multiple_items_snackbar)
)
}
}
dialog.dismiss()
}
create()
}.show()
}
}
setHasOptionsMenu(false) private fun deleteDownloadItems(items: Set<DownloadItem>) {
updatePendingDownloadToDelete(items)
undoScope = CoroutineScope(IO)
undoScope?.allowUndo(
requireView(),
getMultiSelectSnackBarMessage(items),
getString(R.string.bookmark_undo_deletion),
{
undoPendingDeletion(items)
},
getDeleteDownloadItemsOperation(items)
)
} }
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -104,7 +167,52 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
showToolbar(getString(R.string.library_downloads)) showToolbar(getString(R.string.library_downloads))
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val menuRes = when (downloadStore.state.mode) {
is DownloadFragmentState.Mode.Normal -> R.menu.library_menu
is DownloadFragmentState.Mode.Editing -> R.menu.download_select_multi
}
inflater.inflate(menuRes, menu)
menu.findItem(R.id.delete_downloads_multi_select)?.title =
SpannableString(getString(R.string.bookmark_menu_delete_button)).apply {
setTextColor(requireContext(), R.attr.destructive)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.close_history -> {
close()
true
}
R.id.delete_downloads_multi_select -> {
deleteDownloadItems(downloadStore.state.mode.selectedItems)
downloadStore.dispatch(DownloadFragmentAction.ExitEditMode)
true
}
else -> super.onOptionsItemSelected(item)
}
private fun getMultiSelectSnackBarMessage(downloadItems: Set<DownloadItem>): String {
return if (downloadItems.size > 1) {
getString(R.string.download_delete_multiple_items_snackbar)
} else {
String.format(
requireContext().getString(
R.string.history_delete_single_item_snackbar
), downloadItems.first().fileName
)
}
}
override fun onPause() {
invokePendingDeletion()
super.onPause()
}
override fun onBackPressed(): Boolean { override fun onBackPressed(): Boolean {
invokePendingDeletion()
return downloadView.onBackPressed() return downloadView.onBackPressed()
} }
@ -119,4 +227,41 @@ class DownloadFragment : LibraryPageFragment<DownloadItem>(), UserInteractionHan
) )
} }
} }
private fun getDeleteDownloadItemsOperation(items: Set<DownloadItem>): (suspend () -> Unit) {
return {
CoroutineScope(IO).launch {
downloadStore.dispatch(DownloadFragmentAction.EnterDeletionMode)
context?.let {
for (item in items) {
it.components.useCases.downloadUseCases.removeDownload(item.id)
}
}
downloadStore.dispatch(DownloadFragmentAction.ExitDeletionMode)
pendingDownloadDeletionJob = null
}
}
}
private fun updatePendingDownloadToDelete(items: Set<DownloadItem>) {
pendingDownloadDeletionJob = getDeleteDownloadItemsOperation(items)
val ids = items.map { item -> item.id }.toSet()
downloadStore.dispatch(DownloadFragmentAction.AddPendingDeletionSet(ids))
}
private fun undoPendingDeletion(items: Set<DownloadItem>) {
pendingDownloadDeletionJob = null
val ids = items.map { item -> item.id }.toSet()
downloadStore.dispatch(DownloadFragmentAction.UndoPendingDeletionSet(ids))
}
private fun invokePendingDeletion() {
pendingDownloadDeletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.invoke()
}.invokeOnCompletion {
pendingDownloadDeletionJob = null
}
}
}
} }

@ -10,7 +10,7 @@ import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store import mozilla.components.lib.state.Store
/** /**
* Class representing a history entry * Class representing a downloads entry
* @property id Unique id of the download item * @property id Unique id of the download item
* @property fileName File name of the download item * @property fileName File name of the download item
* @property filePath Full path of the download item * @property filePath Full path of the download item
@ -35,8 +35,15 @@ class DownloadFragmentStore(initialState: DownloadFragmentState) :
/** /**
* Actions to dispatch through the `DownloadStore` to modify `DownloadState` through the reducer. * Actions to dispatch through the `DownloadStore` to modify `DownloadState` through the reducer.
*/ */
sealed class DownloadFragmentAction : Action { sealed class DownloadFragmentAction : Action {
object ExitEditMode : DownloadFragmentAction() object ExitEditMode : DownloadFragmentAction()
data class AddItemForRemoval(val item: DownloadItem) : DownloadFragmentAction()
data class RemoveItemForRemoval(val item: DownloadItem) : DownloadFragmentAction()
data class AddPendingDeletionSet(val itemIds: Set<String>) : DownloadFragmentAction()
data class UndoPendingDeletionSet(val itemIds: Set<String>) : DownloadFragmentAction()
object EnterDeletionMode : DownloadFragmentAction()
object ExitDeletionMode : DownloadFragmentAction()
} }
/** /**
@ -46,7 +53,9 @@ sealed class DownloadFragmentAction : Action {
*/ */
data class DownloadFragmentState( data class DownloadFragmentState(
val items: List<DownloadItem>, val items: List<DownloadItem>,
val mode: Mode val mode: Mode,
val pendingDeletionIds: Set<String>,
val isDeletingItems: Boolean
) : State { ) : State {
sealed class Mode { sealed class Mode {
open val selectedItems = emptySet<DownloadItem>() open val selectedItems = emptySet<DownloadItem>()
@ -64,6 +73,28 @@ private fun downloadStateReducer(
action: DownloadFragmentAction action: DownloadFragmentAction
): DownloadFragmentState { ): DownloadFragmentState {
return when (action) { return when (action) {
is DownloadFragmentAction.AddItemForRemoval ->
state.copy(mode = DownloadFragmentState.Mode.Editing(state.mode.selectedItems + action.item))
is DownloadFragmentAction.RemoveItemForRemoval -> {
val selected = state.mode.selectedItems - action.item
state.copy(
mode = if (selected.isEmpty()) {
DownloadFragmentState.Mode.Normal
} else {
DownloadFragmentState.Mode.Editing(selected)
}
)
}
is DownloadFragmentAction.ExitEditMode -> state.copy(mode = DownloadFragmentState.Mode.Normal) is DownloadFragmentAction.ExitEditMode -> state.copy(mode = DownloadFragmentState.Mode.Normal)
is DownloadFragmentAction.EnterDeletionMode -> state.copy(isDeletingItems = true)
is DownloadFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false)
is DownloadFragmentAction.AddPendingDeletionSet ->
state.copy(
pendingDeletionIds = state.pendingDeletionIds + action.itemIds
)
is DownloadFragmentAction.UndoPendingDeletionSet ->
state.copy(
pendingDeletionIds = state.pendingDeletionIds - action.itemIds
)
} }
} }

@ -15,11 +15,27 @@ class DownloadInteractor(
downloadController.handleOpen(item) downloadController.handleOpen(item)
} }
override fun select(item: DownloadItem) { /* noop */ } override fun select(item: DownloadItem) {
downloadController.handleSelect(item)
}
override fun deselect(item: DownloadItem) { /* noop */ } override fun deselect(item: DownloadItem) {
downloadController.handleDeselect(item)
}
override fun onBackPressed(): Boolean { override fun onBackPressed(): Boolean {
return downloadController.handleBackPressed() return downloadController.handleBackPressed()
} }
override fun onModeSwitched() {
downloadController.handleModeSwitched()
}
override fun onDeleteSome(items: Set<DownloadItem>) {
downloadController.handleDeleteSome(items)
}
override fun onDeleteAll() {
downloadController.handleDeleteAll()
}
} }

@ -0,0 +1,44 @@
/* 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.library.downloads
import android.content.Context
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.menu2.BrowserMenuController
import mozilla.components.concept.menu.MenuController
import mozilla.components.concept.menu.candidate.TextMenuCandidate
import mozilla.components.concept.menu.candidate.TextStyle
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.R
class DownloadItemMenu(
private val context: Context,
private val onItemTapped: (Item) -> Unit
) {
enum class Item {
Delete
}
val menuController: MenuController by lazy {
BrowserMenuController().apply {
submitList(menuItems())
}
}
@VisibleForTesting
internal fun menuItems(): List<TextMenuCandidate> {
return listOf(
TextMenuCandidate(
text = context.getString(R.string.history_delete_item),
textStyle = TextStyle(
color = context.getColorFromAttr(R.attr.destructive)
)
) {
onItemTapped.invoke(Item.Delete)
}
)
}
}

@ -12,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import kotlinx.android.synthetic.main.component_downloads.* import kotlinx.android.synthetic.main.component_downloads.*
import kotlinx.android.synthetic.main.component_downloads.view.* import kotlinx.android.synthetic.main.component_downloads.view.*
import kotlinx.android.synthetic.main.component_history.view.progress_bar
import kotlinx.android.synthetic.main.component_history.view.swipe_refresh
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.library.LibraryPageView import org.mozilla.fenix.library.LibraryPageView
@ -27,6 +29,22 @@ interface DownloadViewInteractor : SelectionInteractor<DownloadItem> {
* Called on backpressed to exit edit mode * Called on backpressed to exit edit mode
*/ */
fun onBackPressed(): Boolean fun onBackPressed(): Boolean
/**
* Called when the mode is switched so we can invalidate the menu
*/
fun onModeSwitched()
/**
* Called when multiple downloads items are deleted
* @param items the downloads items to delete
*/
fun onDeleteSome(items: Set<DownloadItem>)
/**
* Called when all downloads items are deleted
*/
fun onDeleteAll()
} }
/** /**
@ -55,18 +73,41 @@ class DownloadView(
} }
fun update(state: DownloadFragmentState) { fun update(state: DownloadFragmentState) {
val oldMode = mode
view.progress_bar.isVisible = state.isDeletingItems
view.swipe_refresh.isEnabled = false view.swipe_refresh.isEnabled = false
mode = state.mode mode = state.mode
updateEmptyState(state.items.isNotEmpty()) downloadAdapter.updatePendingDeletionIds(state.pendingDeletionIds)
updateEmptyState(state.pendingDeletionIds.size != state.items.size)
downloadAdapter.updateMode(state.mode) downloadAdapter.updateMode(state.mode)
downloadAdapter.updateDownloads(state.items) downloadAdapter.updateDownloads(state.items)
setUiForNormalMode( if (state.mode::class != oldMode::class) {
context.getString(R.string.library_downloads) interactor.onModeSwitched()
) }
when (val mode = state.mode) {
is DownloadFragmentState.Mode.Normal -> {
setUiForNormalMode(
context.getString(R.string.library_downloads)
)
}
is DownloadFragmentState.Mode.Editing -> {
val unselectedItems = oldMode.selectedItems - state.mode.selectedItems
state.mode.selectedItems.union(unselectedItems).forEach { item ->
val index = state.items.indexOf(item)
downloadAdapter.notifyItemChanged(index)
}
setUiForSelectingMode(
context.getString(R.string.download_multi_select_title, mode.selectedItems.size)
)
}
}
} }
fun updateEmptyState(userHasDownloads: Boolean) { fun updateEmptyState(userHasDownloads: Boolean) {

@ -5,16 +5,19 @@
package org.mozilla.fenix.library.downloads.viewholders package org.mozilla.fenix.library.downloads.viewholders
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.download_list_item.view.* import kotlinx.android.synthetic.main.download_list_item.view.*
import kotlinx.android.synthetic.main.library_site_item.view.* import kotlinx.android.synthetic.main.library_site_item.view.*
import mozilla.components.feature.downloads.toMegabyteOrKilobyteString import mozilla.components.feature.downloads.toMegabyteOrKilobyteString
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.SelectionHolder
import org.mozilla.fenix.library.downloads.DownloadInteractor import org.mozilla.fenix.library.downloads.DownloadInteractor
import org.mozilla.fenix.library.downloads.DownloadItem import org.mozilla.fenix.library.downloads.DownloadItem
import org.mozilla.fenix.ext.getIcon import org.mozilla.fenix.ext.getIcon
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.downloads.DownloadFragmentState
import org.mozilla.fenix.library.downloads.DownloadItemMenu
class DownloadsListItemViewHolder( class DownloadsListItemViewHolder(
view: View, view: View,
@ -24,24 +27,77 @@ class DownloadsListItemViewHolder(
private var item: DownloadItem? = null private var item: DownloadItem? = null
init {
setupMenu()
itemView.delete_downloads_button.setOnClickListener {
val selected = selectionHolder.selectedItems
if (selected.isEmpty()) {
downloadInteractor.onDeleteAll()
} else {
downloadInteractor.onDeleteSome(selected)
}
}
}
fun bind( fun bind(
item: DownloadItem item: DownloadItem,
showDeleteButton: Boolean,
mode: DownloadFragmentState.Mode,
isPendingDeletion: Boolean = false
) { ) {
itemView.download_layout.visibility = View.VISIBLE itemView.download_layout.visibility = if (isPendingDeletion) {
View.GONE
} else {
View.VISIBLE
}
itemView.download_layout.titleView.text = item.fileName itemView.download_layout.titleView.text = item.fileName
itemView.download_layout.urlView.text = item.size.toLong().toMegabyteOrKilobyteString() itemView.download_layout.urlView.text = item.size.toLong().toMegabyteOrKilobyteString()
toggleTopContent(showDeleteButton, mode == DownloadFragmentState.Mode.Normal)
itemView.download_layout.setSelectionInteractor(item, selectionHolder, downloadInteractor) itemView.download_layout.setSelectionInteractor(item, selectionHolder, downloadInteractor)
itemView.download_layout.changeSelected(item in selectionHolder.selectedItems) itemView.download_layout.changeSelected(item in selectionHolder.selectedItems)
itemView.overflow_menu.hideAndDisable()
itemView.favicon.setImageResource(item.getIcon()) itemView.favicon.setImageResource(item.getIcon())
itemView.favicon.isClickable = false
itemView.overflow_menu.showAndEnable()
this.item = item this.item = item
} }
private fun toggleTopContent(
showTopContent: Boolean,
isNormalMode: Boolean
) {
itemView.delete_downloads_button.isVisible = showTopContent
if (showTopContent) {
itemView.delete_downloads_button.run {
if (isNormalMode) {
isEnabled = true
alpha = 1f
} else {
isEnabled = false
alpha = DELETE_BUTTON_DISABLED_ALPHA
}
}
}
}
private fun setupMenu() {
val downloadMenu = DownloadItemMenu(itemView.context) {
val item = this.item ?: return@DownloadItemMenu
if (it == DownloadItemMenu.Item.Delete) {
downloadInteractor.onDeleteSome(setOf(item))
}
}
itemView.download_layout.attachMenu(downloadMenu.menuController)
}
companion object { companion object {
const val DELETE_BUTTON_DISABLED_ALPHA = 0.4f
const val LAYOUT_ID = R.layout.download_list_item const val LAYOUT_ID = R.layout.download_list_item
} }
} }

@ -9,6 +9,13 @@
android:layout_width="match_parent" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"> android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/delete_downloads_button"
style="@style/DestructiveButton"
android:layout_marginHorizontal="16dp"
android:text="@string/download_delete_all"
android:visibility="gone" />
<org.mozilla.fenix.library.LibrarySiteItemView <org.mozilla.fenix.library.LibrarySiteItemView
android:id="@+id/download_layout" android:id="@+id/download_layout"
android:layout_width="match_parent" android:layout_width="match_parent"

@ -0,0 +1,11 @@
<?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/. -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/delete_downloads_multi_select"
android:title="@string/download_delete_item"
app:showAsAction="never" />
</menu>

@ -665,11 +665,22 @@
<string name="history_empty_message">No history here</string> <string name="history_empty_message">No history here</string>
<!-- Downloads --> <!-- Downloads -->
<!-- Text for the button to clear all downloads -->
<string name="download_delete_all">Delete downloads</string>
<!-- Text for the dialog to confirm clearing all downloads -->
<string name="download_delete_all_dialog">Are you sure you want to clear your downloads?</string>
<!-- Text for the snackbar to confirm that multiple downloads items have been deleted -->
<string name="download_delete_multiple_items_snackbar">Downloads Deleted</string>
<!-- Text shown when no download exists --> <!-- Text shown when no download exists -->
<string name="download_empty_message">No downloads here</string> <string name="download_empty_message">No downloads here</string>
<!-- History multi select title in app bar <!-- History multi select title in app bar
The first parameter is the number of downloads selected --> The first parameter is the number of downloads selected -->
<string name="download_multi_select_title">%1$d selected</string> <string name="download_multi_select_title">%1$d selected</string>
<!-- History overflow menu open in new tab button -->
<string name="download_menu_open">Open</string>
<!-- Text for the button to delete a single history item -->
<string name="download_delete_item">Delete</string>
<!-- Crashes --> <!-- Crashes -->
<!-- Title text displayed on the tab crash page. This first parameter is the name of the application (For example: Fenix) --> <!-- Title text displayed on the tab crash page. This first parameter is the name of the application (For example: Fenix) -->

@ -31,7 +31,7 @@ class DownloadAdapterTest {
} }
@Test @Test
fun `getItemCount should return the number of tab collections`() { fun `getItemCount should return the number of downloads`() {
val download = mockk<DownloadItem>() val download = mockk<DownloadItem>()
assertEquals(0, adapter.itemCount) assertEquals(0, adapter.itemCount)

@ -26,9 +26,15 @@ class DownloadControllerTest {
private val store: DownloadFragmentStore = mockk(relaxed = true) private val store: DownloadFragmentStore = mockk(relaxed = true)
private val state: DownloadFragmentState = mockk(relaxed = true) private val state: DownloadFragmentState = mockk(relaxed = true)
private val openToFileManager: (DownloadItem, BrowsingMode?) -> Unit = mockk(relaxed = true) private val openToFileManager: (DownloadItem, BrowsingMode?) -> Unit = mockk(relaxed = true)
private val displayDeleteAll: () -> Unit = mockk(relaxed = true)
private val invalidateOptionsMenu: () -> Unit = mockk(relaxed = true)
private val deleteDownloadItems: (Set<DownloadItem>) -> Unit = mockk(relaxed = true)
private val controller = DefaultDownloadController( private val controller = DefaultDownloadController(
store, store,
openToFileManager openToFileManager,
displayDeleteAll,
invalidateOptionsMenu,
deleteDownloadItems
) )
@Before @Before
@ -65,4 +71,55 @@ class DownloadControllerTest {
assertFalse(controller.handleBackPressed()) assertFalse(controller.handleBackPressed())
} }
@Test
fun onPressDownloadItemInEditMode() {
every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf())
controller.handleSelect(downloadItem)
verify {
store.dispatch(DownloadFragmentAction.AddItemForRemoval(downloadItem))
}
}
@Test
fun onPressSelectedDownloadItemInEditMode() {
every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf(downloadItem))
controller.handleDeselect(downloadItem)
verify {
store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(downloadItem))
}
}
@Test
fun onModeSwitched() {
controller.handleModeSwitched()
verify {
invalidateOptionsMenu.invoke()
}
}
@Test
fun onDeleteAll() {
controller.handleDeleteAll()
verify {
displayDeleteAll.invoke()
}
}
@Test
fun onDeleteSome() {
val itemsToDelete = setOf(downloadItem)
controller.handleDeleteSome(itemsToDelete)
verify {
deleteDownloadItems(itemsToDelete)
}
}
} }

@ -0,0 +1,70 @@
/* 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.library.downloads
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.content.DownloadState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class DownloadFragmentStoreTest {
private val downloadItem = DownloadItem("0", "title", "url", "77", "jpg", DownloadState.Status.COMPLETED)
private val newDownloadItem = DownloadItem("1", "title", "url", "77", "jpg", DownloadState.Status.COMPLETED)
@Test
fun exitEditMode() = runBlocking {
val initialState = oneItemEditState()
val store = DownloadFragmentStore(initialState)
store.dispatch(DownloadFragmentAction.ExitEditMode).join()
assertNotSame(initialState, store.state)
assertEquals(store.state.mode, DownloadFragmentState.Mode.Normal)
}
@Test
fun itemAddedForRemoval() = runBlocking {
val initialState = emptyDefaultState()
val store = DownloadFragmentStore(initialState)
store.dispatch(DownloadFragmentAction.AddItemForRemoval(newDownloadItem)).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
DownloadFragmentState.Mode.Editing(setOf(newDownloadItem))
)
}
@Test
fun removeItemForRemoval() = runBlocking {
val initialState = twoItemEditState()
val store = DownloadFragmentStore(initialState)
store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(newDownloadItem)).join()
assertNotSame(initialState, store.state)
assertEquals(store.state.mode, DownloadFragmentState.Mode.Editing(setOf(downloadItem)))
}
private fun emptyDefaultState(): DownloadFragmentState = DownloadFragmentState(
items = listOf(),
mode = DownloadFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
private fun oneItemEditState(): DownloadFragmentState = DownloadFragmentState(
items = listOf(),
mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem)),
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
private fun twoItemEditState(): DownloadFragmentState = DownloadFragmentState(
items = listOf(),
mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem, newDownloadItem)),
pendingDeletionIds = emptySet(),
isDeletingItems = false
)
}

@ -25,6 +25,24 @@ class DownloadInteractorTest {
} }
} }
@Test
fun onSelect() {
interactor.select(downloadItem)
verifyAll {
controller.handleSelect(downloadItem)
}
}
@Test
fun onDeselect() {
interactor.deselect(downloadItem)
verifyAll {
controller.handleDeselect(downloadItem)
}
}
@Test @Test
fun onBackPressed() { fun onBackPressed() {
every { every {
@ -38,4 +56,32 @@ class DownloadInteractorTest {
} }
assertTrue(backpressHandled) assertTrue(backpressHandled)
} }
@Test
fun onModeSwitched() {
interactor.onModeSwitched()
verifyAll {
controller.handleModeSwitched()
}
}
@Test
fun onDeleteAll() {
interactor.onDeleteAll()
verifyAll {
controller.handleDeleteAll()
}
}
@Test
fun onDeleteSome() {
val items = setOf(downloadItem)
interactor.onDeleteSome(items)
verifyAll {
controller.handleDeleteSome(items)
}
}
} }

Loading…
Cancel
Save