Merge pull request #13982 from nextcloud/trashbin-multiple-selection

Add Bulk Action Support to Trashbin
This commit is contained in:
Tobias Kaminsky 2024-12-13 10:26:46 +01:00 committed by GitHub
commit 3e656486c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 849 additions and 37 deletions

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
@ -123,6 +124,7 @@ import com.owncloud.android.ui.preview.PreviewTextFileFragment;
import com.owncloud.android.ui.preview.PreviewTextFragment;
import com.owncloud.android.ui.preview.PreviewTextStringFragment;
import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment;
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet;
import com.owncloud.android.ui.trashbin.TrashbinActivity;
import androidx.annotation.OptIn;
@ -225,6 +227,9 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract TrashbinActivity trashbinActivity();
@ContributesAndroidInjector
abstract TrashbinFileActionsBottomSheet trashbinFileActionsBottomSheet();
@ContributesAndroidInjector
abstract UploadFilesActivity uploadFilesActivity();

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
@ -13,6 +14,7 @@ import com.nextcloud.client.etm.EtmViewModel
import com.nextcloud.client.logger.ui.LogsViewModel
import com.nextcloud.ui.fileactions.FileActionsViewModel
import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel
import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
import dagger.Binds
import dagger.Module
@ -50,6 +52,11 @@ abstract class ViewModelModule {
@ViewModelKey(DocumentScanViewModel::class)
abstract fun documentScanViewModel(vm: DocumentScanViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(TrashbinFileActionsViewModel::class)
abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel
@Binds
abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
}

View file

@ -0,0 +1,32 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.ui.trashbinFileActions
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import com.owncloud.android.R
enum class TrashbinFileAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) {
DELETE_PERMANENTLY(R.id.action_delete, R.string.trashbin_file_remove, R.drawable.ic_delete),
RESTORE(R.id.restore, R.string.restore_item, R.drawable.ic_history),
SELECT_ALL(R.id.action_select_all_action_menu, R.string.select_all, R.drawable.ic_select_all),
SELECT_NONE(R.id.action_deselect_all_action_menu, R.string.deselect_all, R.drawable.ic_select_none);
companion object {
/**
* All file actions, in the order they should be displayed
*/
@JvmField
val SORTED_VALUES = listOf(
DELETE_PERMANENTLY,
RESTORE,
SELECT_ALL,
SELECT_NONE
)
}
}

View file

@ -0,0 +1,233 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.ui.trashbinFileActions
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.os.bundleOf
import androidx.core.view.isEmpty
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.di.ViewModelFactory
import com.nextcloud.utils.extensions.toOCFile
import com.owncloud.android.R
import com.owncloud.android.databinding.FileActionsBottomSheetBinding
import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import javax.inject.Inject
class TrashbinFileActionsBottomSheet : BottomSheetDialogFragment(), Injectable {
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var vmFactory: ViewModelFactory
@Inject
lateinit var currentUserProvider: CurrentAccountProvider
@Inject
lateinit var storageManager: FileDataStorageManager
@Inject
lateinit var syncedFolderProvider: SyncedFolderProvider
private lateinit var viewModel: TrashbinFileActionsViewModel
private var _binding: FileActionsBottomSheetBinding? = null
val binding
get() = _binding!!
private val thumbnailAsyncTasks = mutableListOf<ThumbnailsCacheManager.ThumbnailGenerationTask>()
fun interface ResultListener {
fun onResult(@IdRes actionId: Int)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
viewModel = ViewModelProvider(this, vmFactory)[TrashbinFileActionsViewModel::class.java]
_binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
viewModel.uiState.observe(viewLifecycleOwner, this::handleState)
viewModel.clickActionId.observe(viewLifecycleOwner) { id ->
dispatchActionClick(id)
}
viewModel.load(requireArguments())
val bottomSheetDialog = dialog as BottomSheetDialog
bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
bottomSheetDialog.behavior.skipCollapsed = true
viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE)
return binding.root
}
private fun handleState(state: TrashbinFileActionsViewModel.UiState) {
toggleLoadingOrContent(state)
when (state) {
is TrashbinFileActionsViewModel.UiState.LoadedForSingleFile -> {
loadFileThumbnail(state.titleFile)
displayActions(state.actions)
displayTitle(state.titleFile)
}
is TrashbinFileActionsViewModel.UiState.LoadedForMultipleFiles -> {
setMultipleFilesThumbnail()
displayActions(state.actions)
displayTitle(state.fileCount)
}
TrashbinFileActionsViewModel.UiState.Loading -> {}
TrashbinFileActionsViewModel.UiState.Error -> {
context?.let {
Toast.makeText(it, R.string.error_file_actions, Toast.LENGTH_SHORT).show()
}
dismissAllowingStateLoss()
}
}
}
private fun loadFileThumbnail(titleFile: TrashbinFile?) {
titleFile?.let {
DisplayUtils.setThumbnail(
it.toOCFile(),
binding.thumbnailLayout.thumbnail,
currentUserProvider.user,
storageManager,
thumbnailAsyncTasks,
false,
context,
binding.thumbnailLayout.thumbnailShimmer,
syncedFolderProvider.preferences,
viewThemeUtils,
syncedFolderProvider
)
}
}
private fun setMultipleFilesThumbnail() {
context?.let {
val drawable = viewThemeUtils.platform.tintDrawable(it, R.drawable.file_multiple, ColorRole.PRIMARY)
binding.thumbnailLayout.thumbnail.setImageDrawable(drawable)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
fun setResultListener(
fragmentManager: FragmentManager,
lifecycleOwner: LifecycleOwner,
listener: ResultListener
): TrashbinFileActionsBottomSheet {
fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result ->
@IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1)
if (actionId != -1) {
listener.onResult(actionId)
}
}
return this
}
private fun toggleLoadingOrContent(state: TrashbinFileActionsViewModel.UiState) {
if (state is TrashbinFileActionsViewModel.UiState.Loading) {
binding.bottomSheetLoading.isVisible = true
binding.bottomSheetHeader.isVisible = false
viewThemeUtils.platform.colorCircularProgressBar(binding.bottomSheetLoading, ColorRole.PRIMARY)
} else {
binding.bottomSheetLoading.isVisible = false
binding.bottomSheetHeader.isVisible = true
}
}
private fun displayActions(actions: List<TrashbinFileAction>) {
if (binding.fileActionsList.isEmpty()) {
actions.forEach { action ->
val view = inflateActionView(action)
binding.fileActionsList.addView(view)
}
}
}
private fun displayTitle(titleFile: TrashbinFile?) {
titleFile?.fileName?.let {
binding.title.text = it
} ?: { binding.title.isVisible = false }
}
private fun displayTitle(fileCount: Int) {
binding.title.text = resources.getQuantityString(R.plurals.trashbin_list__footer__file, fileCount, fileCount)
}
private fun inflateActionView(action: TrashbinFileAction): View {
val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
.apply {
root.setOnClickListener {
viewModel.onClick(action)
}
text.setText(action.title)
if (action.icon != null) {
val drawable =
viewThemeUtils.platform.tintDrawable(
requireContext(),
AppCompatResources.getDrawable(requireContext(), action.icon)!!
)
icon.setImageDrawable(drawable)
}
}
return itemBinding.root
}
private fun dispatchActionClick(id: Int?) {
if (id != null) {
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id))
parentFragmentManager.clearFragmentResultListener(REQUEST_KEY)
dismiss()
}
}
companion object {
private const val REQUEST_KEY = "REQUEST_KEY_ACTION"
private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID"
@JvmStatic
fun newInstance(numberOfAllFiles: Int, files: Collection<TrashbinFile>): TrashbinFileActionsBottomSheet {
return TrashbinFileActionsBottomSheet().apply {
val argsBundle = bundleOf(
TrashbinFileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles,
TrashbinFileActionsViewModel.ARG_FILES to ArrayList<TrashbinFile>(files)
)
arguments = argsBundle
}
}
}
}

View file

@ -0,0 +1,101 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.ui.trashbinFileActions
import android.os.Bundle
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nextcloud.client.logger.Logger
import com.nextcloud.ui.fileactions.FileActionsViewModel
import com.owncloud.android.R
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
class TrashbinFileActionsViewModel @Inject constructor(
private val logger: Logger
) : ViewModel() {
sealed interface UiState {
data object Loading : UiState
data object Error : UiState
data class LoadedForSingleFile(
val actions: List<TrashbinFileAction>,
val titleFile: TrashbinFile?
) : UiState
data class LoadedForMultipleFiles(val actions: List<TrashbinFileAction>, val fileCount: Int) : UiState
}
private val _uiState: MutableLiveData<UiState> = MutableLiveData(UiState.Loading)
val uiState: LiveData<UiState>
get() = _uiState
private val _clickActionId: MutableLiveData<Int?> = MutableLiveData(null)
val clickActionId: LiveData<Int?>
@IdRes
get() = _clickActionId
fun load(arguments: Bundle) {
val files: List<TrashbinFile>? = arguments.getParcelableArrayList(ARG_FILES)
val numberOfAllFiles: Int = arguments.getInt(FileActionsViewModel.ARG_ALL_FILES_COUNT, 1)
if (files.isNullOrEmpty()) {
logger.d(TAG, "No valid files argument for loading actions")
_uiState.postValue(UiState.Error)
} else {
load(files.toList(), numberOfAllFiles)
}
}
private fun load(files: Collection<TrashbinFile>, numberOfAllFiles: Int?) {
viewModelScope.launch(Dispatchers.IO) {
val toHide = getHiddenActions(numberOfAllFiles, files)
val availableActions = getActionsToShow(toHide)
updateStateLoaded(files, availableActions)
}
}
private fun getHiddenActions(numberOfAllFiles: Int?, files: Collection<TrashbinFile>): List<Int> {
numberOfAllFiles?.let {
if (files.size >= it) {
return listOf(R.id.action_select_all_action_menu)
}
}
return listOf()
}
private fun getActionsToShow(toHide: List<Int>) = TrashbinFileAction.SORTED_VALUES.filter { it.id !in toHide }
private fun updateStateLoaded(files: Collection<TrashbinFile>, availableActions: List<TrashbinFileAction>) {
val state: UiState = when (files.size) {
1 -> {
val file = files.first()
UiState.LoadedForSingleFile(availableActions, file)
}
else -> UiState.LoadedForMultipleFiles(availableActions, files.size)
}
_uiState.postValue(state)
}
fun onClick(action: TrashbinFileAction) {
_clickActionId.value = action.id
}
companion object {
const val ARG_ALL_FILES_COUNT = "ALL_FILES_COUNT"
const val ARG_FILES = "FILES"
private val TAG = TrashbinFileActionsViewModel::class.simpleName!!
}
}

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2023 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -17,6 +18,8 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -79,3 +82,12 @@ fun Long.getFormattedStringDate(format: String): String {
val simpleDateFormat = SimpleDateFormat(format, Locale.getDefault())
return simpleDateFormat.format(Date(this))
}
fun TrashbinFile.toOCFile(): OCFile {
val ocFile = OCFile(this.remotePath)
ocFile.mimeType = this.mimeType
ocFile.fileLength = this.fileLength
ocFile.remoteId = this.remoteId
ocFile.fileName = this.fileName
return ocFile
}

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2018 Tobias Kaminsky
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -16,12 +17,15 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import com.nextcloud.android.common.ui.theme.utils.ColorRole;
import com.nextcloud.client.account.User;
import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.utils.extensions.ViewExtensionsKt;
import com.owncloud.android.R;
import com.owncloud.android.databinding.ListFooterBinding;
import com.owncloud.android.databinding.TrashbinItemBinding;
import com.owncloud.android.datamodel.FileDataStorageManager;
import com.owncloud.android.datamodel.SyncedFolderProvider;
import com.owncloud.android.datamodel.ThumbnailsCacheManager;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile;
@ -32,11 +36,16 @@ import com.owncloud.android.utils.MimeTypeUtil;
import com.owncloud.android.utils.theme.ViewThemeUtils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import static com.nextcloud.utils.extensions.ViewExtensionsKt.createRoundedOutline;
import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR;
import static com.owncloud.android.datamodel.OCFile.ROOT_PATH;
@ -57,11 +66,16 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
private final AppPreferences preferences;
private final List<ThumbnailsCacheManager.ThumbnailGenerationTask> asyncTasks = new ArrayList<>();
private final ViewThemeUtils viewThemeUtils;
private final SyncedFolderProvider syncedFolderProvider;
private final Set<TrashbinFile> checkedFiles = new HashSet<>();
private boolean isMultiSelect = false;
public TrashbinListAdapter(
TrashbinActivityInterface trashbinActivityInterface,
FileDataStorageManager storageManager,
AppPreferences preferences,
SyncedFolderProvider syncedFolderProvider,
Context context,
User user,
ViewThemeUtils viewThemeUtils
@ -72,6 +86,7 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
this.storageManager = storageManager;
this.preferences = preferences;
this.context = context;
this.syncedFolderProvider = syncedFolderProvider;
this.viewThemeUtils = viewThemeUtils;
}
@ -110,6 +125,7 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
// layout
trashbinFileViewHolder.binding.ListItemLayout.setOnClickListener(v -> trashbinActivityInterface.onItemClicked(file));
trashbinFileViewHolder.binding.ListItemLayout.setOnLongClickListener(v -> trashbinActivityInterface.onLongItemClicked(file));
// thumbnail
trashbinFileViewHolder.binding.thumbnail.setTag(file.getRemoteId());
@ -136,15 +152,44 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
file.getDeletionTimestamp() * 1000));
// checkbox
trashbinFileViewHolder.binding.customCheckbox.setVisibility(View.GONE);
if (isCheckedFile(file)) {
trashbinFileViewHolder.binding.customCheckbox.setImageDrawable(
viewThemeUtils.platform.tintDrawable(context, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY));
} else {
trashbinFileViewHolder.binding.customCheckbox.setImageResource(R.drawable.ic_checkbox_blank_outline);
}
trashbinFileViewHolder.binding.customCheckbox.setVisibility(isMultiSelect ? View.VISIBLE : View.GONE);
trashbinFileViewHolder.binding.restore.setVisibility(isMultiSelect ? View.GONE : View.VISIBLE);
ViewExtensionsKt.setVisibleIf(trashbinFileViewHolder.binding.overflowMenu, !isMultiSelect());
// overflow menu
trashbinFileViewHolder.binding.overflowMenu.setOnClickListener(v ->
trashbinActivityInterface.onOverflowIconClicked(file, v));
// restore button
trashbinFileViewHolder.binding.restore.setOnClickListener(v ->
trashbinActivityInterface.onRestoreIconClicked(file, v));
trashbinFileViewHolder.binding.restore.setOnClickListener(v -> trashbinActivityInterface.onRestoreIconClicked(file));
float cornerRadius = context.getResources().getDimension(R.dimen.selected_grid_container_radius);
boolean isDarkModeActive = (syncedFolderProvider.getPreferences().isDarkModeEnabled());
int selectedItemBackgroundColorId;
if (isDarkModeActive) {
selectedItemBackgroundColorId = R.color.action_mode_background;
} else {
selectedItemBackgroundColorId = R.color.selected_item_background;
}
int itemLayoutBackgroundColorId;
if (isCheckedFile(file)) {
itemLayoutBackgroundColorId = selectedItemBackgroundColorId;
} else {
itemLayoutBackgroundColorId = R.color.bg_default;
}
trashbinFileViewHolder.binding.ListItemLayout.setOutlineProvider(createRoundedOutline(context, cornerRadius));
trashbinFileViewHolder.binding.ListItemLayout.setClipToOutline(true);
trashbinFileViewHolder.binding.ListItemLayout.setBackgroundColor(ContextCompat.getColor(context, itemLayoutBackgroundColorId));
} else {
TrashbinFooterViewHolder trashbinFooterViewHolder = (TrashbinFooterViewHolder) holder;
@ -275,6 +320,18 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
return files.size() + 1;
}
public int getFilesCount() {
return files.size();
}
public void notifyItemChanged(@NonNull TrashbinFile file) {
notifyItemChanged(getItemPosition(file));
}
public int getItemPosition(@NonNull TrashbinFile file) {
return files.indexOf(file);
}
public void cancelAllPendingTasks() {
for (ThumbnailsCacheManager.ThumbnailGenerationTask task : asyncTasks) {
if (task != null) {
@ -295,6 +352,49 @@ public class TrashbinListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
notifyDataSetChanged();
}
@SuppressLint("NotifyDataSetChanged")
public void setMultiSelect(boolean bool) {
isMultiSelect = bool;
notifyDataSetChanged();
}
public boolean isMultiSelect() {
return isMultiSelect;
}
public boolean isCheckedFile(TrashbinFile file) {
return checkedFiles.contains(file);
}
public void addCheckedFile(TrashbinFile file) {
checkedFiles.add(file);
}
public void removeCheckedFile(TrashbinFile file) {
checkedFiles.remove(file);
}
public void addToCheckedFiles(@Nullable List<TrashbinFile> files) {
checkedFiles.addAll(files);
}
public Set<TrashbinFile> getCheckedItems() {
return checkedFiles;
}
public void setCheckedItem(@Nullable Set<TrashbinFile> files) {
checkedFiles.clear();
checkedFiles.addAll(files);
}
public void clearCheckedItems() {
checkedFiles.clear();
}
public void addAllFilesToCheckedFiles() {
addToCheckedFiles(files);
}
public class TrashbinFileViewHolder extends RecyclerView.ViewHolder {
protected TrashbinItemBinding binding;

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -10,11 +11,10 @@ package com.owncloud.android.ui.interfaces;
import android.view.View;
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile;
import com.owncloud.android.ui.adapter.OCFileListAdapter;
/**
* Interface for communication between {@link com.owncloud.android.ui.fragment.OCFileListFragment}
* and {@link OCFileListAdapter}
* Interface for communication between {@link com.owncloud.android.ui.trashbin.TrashbinActivity}
* and {@link com.owncloud.android.ui.adapter.TrashbinListAdapter}
*/
public interface TrashbinActivityInterface {
@ -22,5 +22,7 @@ public interface TrashbinActivityInterface {
void onItemClicked(TrashbinFile file);
void onRestoreIconClicked(TrashbinFile file, View view);
boolean onLongItemClicked(TrashbinFile file);
void onRestoreIconClicked(TrashbinFile file);
}

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-FileCopyrightText: 2018 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH
@ -10,23 +11,32 @@ package com.owncloud.android.ui.trashbin
import android.content.Intent
import android.os.Bundle
import android.view.ActionMode
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.AbsListView
import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.network.ClientFactory
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.client.utils.Throttler
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet
import com.owncloud.android.R
import com.owncloud.android.databinding.TrashbinActivityBinding
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile
import com.owncloud.android.ui.activity.DrawerActivity
import com.owncloud.android.ui.adapter.TrashbinListAdapter
@ -51,6 +61,9 @@ class TrashbinActivity :
@Inject
var preferences: AppPreferences? = null
@Inject
lateinit var syncedFolderProvider: SyncedFolderProvider
@JvmField
@Inject
var accountProvider: CurrentAccountProvider? = null
@ -59,9 +72,8 @@ class TrashbinActivity :
@Inject
var clientFactory: ClientFactory? = null
@JvmField
@Inject
var viewThemeUtils: ViewThemeUtils? = null
lateinit var throttler: Throttler
private var trashbinListAdapter: TrashbinListAdapter? = null
@ -71,6 +83,8 @@ class TrashbinActivity :
private var active = false
lateinit var binding: TrashbinActivityBinding
private var mMultiChoiceModeListener: MultiChoiceModeListener? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
trashbinPresenter?.navigateUp()
@ -134,6 +148,7 @@ class TrashbinActivity :
this,
storageManager,
preferences,
syncedFolderProvider,
this,
user.orElse(accountProvider!!.user),
viewThemeUtils
@ -161,6 +176,13 @@ class TrashbinActivity :
loadFolder()
handleOnBackPressed()
mMultiChoiceModeListener = MultiChoiceModeListener(
this,
trashbinListAdapter,
viewThemeUtils
) { filesCount, checkedFiles -> openActionsMenu(filesCount, checkedFiles) }
addDrawerListener(mMultiChoiceModeListener)
}
private fun handleOnBackPressed() {
@ -171,6 +193,8 @@ class TrashbinActivity :
}
fun loadFolder(onComplete: () -> Unit = {}, onError: () -> Unit = {}) {
// exit action mode on data refresh
mMultiChoiceModeListener?.exitSelectionMode()
trashbinListAdapter?.let {
if (it.itemCount > EMPTY_LIST_COUNT) {
binding.swipeContainingList.isRefreshing = true
@ -205,20 +229,50 @@ class TrashbinActivity :
val popup = PopupMenu(this, view)
popup.inflate(R.menu.item_trashbin)
popup.setOnMenuItemClickListener {
trashbinPresenter?.removeTrashbinFile(file)
onFileActionChosen(it.itemId, setOf(file))
true
}
popup.show()
}
override fun onItemClicked(file: TrashbinFile) {
if (file.isFolder) {
if (trashbinListAdapter?.isMultiSelect == true) {
toggleItemToCheckedList(file)
} else if (file.isFolder) {
trashbinPresenter?.enterFolder(file.remotePath)
}
}
override fun onRestoreIconClicked(file: TrashbinFile, view: View) {
trashbinPresenter?.restoreTrashbinFile(file)
override fun onRestoreIconClicked(file: TrashbinFile) {
trashbinPresenter?.restoreTrashbinFile(listOf(file))
}
override fun onLongItemClicked(file: TrashbinFile): Boolean {
// Create only once instance of action mode
if (mMultiChoiceModeListener?.mActiveActionMode != null) {
toggleItemToCheckedList(file)
} else {
startActionMode(mMultiChoiceModeListener)
trashbinListAdapter?.addCheckedFile(file)
}
mMultiChoiceModeListener?.updateActionModeFile(file)
return true
}
/**
* Will toggle a file selection status from the action mode
*
* @param file The concerned TrashbinFile by the selection/deselection
*/
private fun toggleItemToCheckedList(file: TrashbinFile) {
trashbinListAdapter?.run {
if (isCheckedFile(file)) {
removeCheckedFile(file)
} else {
addCheckedFile(file)
}
}
mMultiChoiceModeListener?.updateActionModeFile(file)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -315,7 +369,262 @@ class TrashbinActivity :
}
}
private fun openActionsMenu(filesCount: Int, checkedFiles: Set<TrashbinFile>) {
throttler.run("overflowClick") {
val supportFragmentManager = supportFragmentManager
TrashbinFileActionsBottomSheet.newInstance(filesCount, checkedFiles)
.setResultListener(
supportFragmentManager,
this
) { id: Int ->
onFileActionChosen(
id,
checkedFiles
)
}
.show(supportFragmentManager, "actions")
}
}
private fun onFileActionChosen(@IdRes itemId: Int, checkedFiles: Set<TrashbinFile>): Boolean {
if (checkedFiles.isEmpty()) {
return false
}
when (itemId) {
R.id.action_delete -> {
trashbinPresenter?.removeTrashbinFile(checkedFiles)
mMultiChoiceModeListener?.exitSelectionMode()
return true
}
R.id.restore -> {
trashbinPresenter?.restoreTrashbinFile(checkedFiles)
mMultiChoiceModeListener?.exitSelectionMode()
return true
}
R.id.action_select_all_action_menu -> {
selectAllFiles(true)
return true
}
R.id.action_deselect_all_action_menu -> {
selectAllFiles(false)
return true
}
else -> return false
}
}
/**
* De-/select all elements in the current list view.
*
* @param select `true` to select all, `false` to deselect all
*/
private fun selectAllFiles(select: Boolean) {
trashbinListAdapter?.let {
if (select) {
it.addAllFilesToCheckedFiles()
} else {
it.clearCheckedItems()
}
for (i in 0 until it.itemCount) {
it.notifyItemChanged(i)
}
mMultiChoiceModeListener?.invalidateActionMode()
}
}
companion object {
const val EMPTY_LIST_COUNT = 1
}
/**
* Handler for multiple selection mode.
*
*
* Manages input from the user when one or more files or folders are selected in the list.
*
*
* Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened and closed.
*/
internal class MultiChoiceModeListener(
val activity: TrashbinActivity,
val adapter: TrashbinListAdapter?,
val viewThemeUtils: ViewThemeUtils,
val openActionsMenu: (Int, Set<TrashbinFile>) -> Unit
) : AbsListView.MultiChoiceModeListener, DrawerLayout.DrawerListener {
var mActiveActionMode: ActionMode? = null
private var mIsActionModeNew = false
/**
* True when action mode is finished because the drawer was opened
*/
private var mActionModeClosedByDrawer = false
/**
* Selected items in list when action mode is closed by drawer
*/
private val mSelectionWhenActionModeClosedByDrawer: MutableSet<TrashbinFile> = HashSet()
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
// nothing to do
}
override fun onDrawerOpened(drawerView: View) {
// nothing to do
}
/**
* When the navigation drawer is closed, action mode is recovered in the same state as was when the drawer was
* (started to be) opened.
*
* @param drawerView Navigation drawer just closed.
*/
override fun onDrawerClosed(drawerView: View) {
if (mActionModeClosedByDrawer && mSelectionWhenActionModeClosedByDrawer.size > 0) {
activity.startActionMode(this)
adapter?.setCheckedItem(mSelectionWhenActionModeClosedByDrawer)
mActiveActionMode?.invalidate()
mSelectionWhenActionModeClosedByDrawer.clear()
}
}
/**
* If the action mode is active when the navigation drawer starts to move, the action mode is closed and the
* selection stored to be recovered when the drawer is closed.
*
* @param newState One of STATE_IDLE, STATE_DRAGGING or STATE_SETTLING.
*/
override fun onDrawerStateChanged(newState: Int) {
if (DrawerLayout.STATE_DRAGGING == newState && mActiveActionMode != null) {
adapter?.let {
mSelectionWhenActionModeClosedByDrawer.addAll(
it.checkedItems
)
}
mActiveActionMode?.finish()
mActionModeClosedByDrawer = true
}
}
/**
* Update action mode bar when an item is selected / unselected in the list
*/
override fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) {
// nothing to do here
}
/**
* Load menu and customize UI when action mode is started.
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mActiveActionMode = mode
// Determine if actionMode is "new" or not (already affected by item-selection)
mIsActionModeNew = true
// fake menu to be able to use bottom sheet instead
val inflater: MenuInflater = activity.menuInflater
inflater.inflate(R.menu.custom_menu_placeholder, menu)
val item = menu.findItem(R.id.custom_menu_placeholder_item)
item.icon?.let {
item.setIcon(
viewThemeUtils.platform.colorDrawable(
it,
ContextCompat.getColor(activity, R.color.white)
)
)
}
mode.invalidate()
// set actionMode color
viewThemeUtils.platform.colorStatusBar(
activity,
ContextCompat.getColor(activity, R.color.action_mode_background)
)
adapter?.setMultiSelect(true)
return true
}
/**
* Updates available action in menu depending on current selection.
*/
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val checkedFiles: Set<TrashbinFile> = adapter?.checkedItems ?: emptySet()
val checkedCount = checkedFiles.size
val title: String =
activity.getResources().getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount)
mode.title = title
// Determine if we need to finish the action mode because there are no items selected
if (checkedCount == 0 && !mIsActionModeNew) {
exitSelectionMode()
}
return true
}
/**
* Exits the multi file selection mode.
*/
fun exitSelectionMode() {
mActiveActionMode?.run {
finish()
}
}
/**
* Will update (invalidate) the action mode adapter/mode to refresh an item selection change
*
* @param file The concerned TrashbinFile to refresh in adapter
*/
fun updateActionModeFile(file: TrashbinFile) {
mIsActionModeNew = false
mActiveActionMode?.let {
it.invalidate()
adapter?.notifyItemChanged(file)
}
}
fun invalidateActionMode() {
mActiveActionMode?.invalidate()
}
/**
* Starts the corresponding action when a menu item is tapped by the user.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
adapter?.let {
val checkedFiles: Set<TrashbinFile> = it.checkedItems
if (item.itemId == R.id.custom_menu_placeholder_item) {
openActionsMenu(it.filesCount, checkedFiles)
}
return true
}
return false
}
/**
* Restores UI.
*/
override fun onDestroyActionMode(mode: ActionMode) {
mActiveActionMode = null
viewThemeUtils.platform.resetStatusBar(activity)
adapter?.setMultiSelect(false)
adapter?.clearCheckedItems()
}
}
}

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2018 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -28,8 +29,8 @@ interface TrashbinContract {
fun loadFolder(onCompleted: () -> Unit = {}, onError: () -> Unit = {})
fun navigateUp()
fun enterFolder(folder: String?)
fun restoreTrashbinFile(file: TrashbinFile?)
fun removeTrashbinFile(file: TrashbinFile?)
fun restoreTrashbinFile(files: Collection<TrashbinFile?>)
fun removeTrashbinFile(files: Collection<TrashbinFile?>)
fun emptyTrashbin()
}
}

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
* SPDX-FileCopyrightText: 2018 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -61,7 +62,8 @@ class TrashbinPresenter(
trashbinView.atRoot(isRoot)
}
override fun restoreTrashbinFile(file: TrashbinFile?) {
override fun restoreTrashbinFile(files: Collection<TrashbinFile?>) {
for (file in files) {
trashbinRepository.restoreFile(
file,
object : TrashbinRepository.OperationCallback {
@ -75,8 +77,10 @@ class TrashbinPresenter(
}
)
}
}
override fun removeTrashbinFile(file: TrashbinFile?) {
override fun removeTrashbinFile(files: Collection<TrashbinFile?>) {
for (file in files) {
trashbinRepository.removeTrashbinFile(
file,
object : TrashbinRepository.OperationCallback {
@ -90,6 +94,7 @@ class TrashbinPresenter(
}
)
}
}
override fun emptyTrashbin() {
trashbinRepository.emptyTrashbin(object : TrashbinRepository.OperationCallback {

View file

@ -584,6 +584,10 @@
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>
<plurals name="trashbin_list__footer__file">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<string name="set_picture_as">Use picture as</string>
<string name="set_as">Set As</string>
@ -880,6 +884,7 @@
<string name="first_run_4_text">Screensharing, online meetings and web conferences</string>
<string name="restore_button_description">Restore deleted file</string>
<string name="restore">Restore file</string>
<string name="restore_item">Restore</string>
<string name="new_version_was_created">New version was created</string>
<string name="new_comment">New comment…</string>
<string name="error_comment_file">Error commenting file</string>