nextcloud-android/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt

347 lines
12 KiB
Kotlin

/*
* Nextcloud Android client application
*
* @author Álvaro Brey
* Copyright (C) 2022 Álvaro Brey
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU AFFERO GENERAL PUBLIC LICENSE for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.nextcloud.ui.fileactions
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.text.style.StyleSpan
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.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.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.OCFile
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.lib.resources.files.model.FileLockType
import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener
import com.owncloud.android.utils.theme.ViewThemeUtils
import javax.inject.Inject
class FileActionsBottomSheet : 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
lateinit var viewModel: FileActionsViewModel
private var _binding: FileActionsBottomSheetBinding? = null
private val binding
get() = _binding!!
lateinit var componentsGetter: ComponentsGetter
private val thumbnailAsyncTasks = mutableListOf<ThumbnailsCacheManager.ThumbnailGenerationTask>()
interface ResultListener {
fun onResult(@IdRes actionId: Int)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
viewModel = ViewModelProvider(this, vmFactory)[FileActionsViewModel::class.java]
_binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
viewModel.uiState.observe(viewLifecycleOwner, this::handleState)
viewModel.clickActionId.observe(viewLifecycleOwner) { id ->
dispatchActionClick(id)
}
viewModel.load(requireArguments(), componentsGetter)
return binding.root
}
private fun handleState(
state: FileActionsViewModel.UiState
) {
toggleLoadingOrContent(state)
when (state) {
is FileActionsViewModel.UiState.LoadedForSingleFile -> {
loadFileThumbnail(state.titleFile)
if (state.lockInfo != null) {
displayLockInfo(state.lockInfo)
}
displayActions(state.actions)
displayTitle(state.titleFile)
}
is FileActionsViewModel.UiState.LoadedForMultipleFiles -> {
setMultipleFilesThumbnail()
displayActions(state.actions)
displayTitle(state.fileCount)
}
FileActionsViewModel.UiState.Loading -> {}
FileActionsViewModel.UiState.Error -> {
context?.let {
Toast.makeText(it, R.string.error_file_actions, Toast.LENGTH_SHORT).show()
}
dismissAllowingStateLoss()
}
}
}
private fun loadFileThumbnail(titleFile: OCFile?) {
titleFile?.let {
DisplayUtils.setThumbnail(
it,
binding.thumbnailLayout.thumbnail,
currentUserProvider.user,
storageManager,
thumbnailAsyncTasks,
false,
context,
binding.thumbnailLayout.thumbnailShimmer,
null,
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
}
override fun onAttach(context: Context) {
super.onAttach(context)
require(context is ComponentsGetter) {
"Context is not a ComponentsGetter"
}
this.componentsGetter = context
}
fun setResultListener(
fragmentManager: FragmentManager,
lifecycleOwner: LifecycleOwner,
listener: ResultListener
): FileActionsBottomSheet {
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: FileActionsViewModel.UiState) {
if (state is FileActionsViewModel.UiState.Loading) {
binding.bottomSheetLoading.isVisible = true
binding.bottomSheetContent.isVisible = false
viewThemeUtils.platform.colorCircularProgressBar(binding.bottomSheetLoading, ColorRole.PRIMARY)
} else {
binding.bottomSheetLoading.isVisible = false
binding.bottomSheetContent.isVisible = true
}
}
private fun displayActions(
actions: List<FileAction>
) {
if (binding.fileActionsList.isEmpty()) {
actions.forEach { action ->
val view = inflateActionView(action)
binding.fileActionsList.addView(view)
}
}
}
private fun displayTitle(titleFile: OCFile?) {
val decryptedFileName = titleFile?.decryptedFileName
if (decryptedFileName != null) {
decryptedFileName.let {
binding.title.text = it
}
} else {
binding.title.isVisible = false
}
}
private fun displayLockInfo(lockInfo: FileActionsViewModel.LockInfo) {
val view = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
.apply {
val textColor = ColorStateList.valueOf(resources.getColor(R.color.secondary_text_color, null))
root.isClickable = false
text.setTextColor(textColor)
text.text = getLockedByText(lockInfo)
if (lockInfo.lockedUntil != null) {
textLine2.text = getLockedUntilText(lockInfo)
textLine2.isVisible = true
}
if (lockInfo.lockType != FileLockType.COLLABORATIVE) {
showLockAvatar(lockInfo)
}
}
binding.fileActionsList.addView(view.root)
}
private fun FileActionsBottomSheetItemBinding.showLockAvatar(lockInfo: FileActionsViewModel.LockInfo) {
val listener = object : AvatarGenerationListener {
override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any?) {
icon.setImageDrawable(avatarDrawable)
}
override fun shouldCallGeneratedCallback(tag: String?, callContext: Any?): Boolean {
return false
}
}
DisplayUtils.setAvatar(
currentUserProvider.user,
lockInfo.lockedBy,
listener,
resources.getDimension(R.dimen.list_item_avatar_icon_radius),
resources,
this,
requireContext()
)
}
private fun getLockedByText(lockInfo: FileActionsViewModel.LockInfo): CharSequence {
val resource = when (lockInfo.lockType) {
FileLockType.COLLABORATIVE -> R.string.locked_by_app
else -> R.string.locked_by
}
return DisplayUtils.createTextWithSpan(
getString(resource, lockInfo.lockedBy),
lockInfo.lockedBy,
StyleSpan(Typeface.BOLD)
)
}
private fun getLockedUntilText(lockInfo: FileActionsViewModel.LockInfo): CharSequence {
val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lockInfo.lockedUntil!!, true)
return getString(R.string.lock_expiration_info, relativeTimestamp)
}
private fun displayTitle(fileCount: Int) {
binding.title.text = resources.getQuantityString(R.plurals.file_list__footer__file, fileCount, fileCount)
}
private fun inflateActionView(action: FileAction): 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
@JvmOverloads
fun newInstance(
file: OCFile,
isOverflow: Boolean,
@IdRes
additionalToHide: List<Int>? = null
): FileActionsBottomSheet {
return newInstance(1, listOf(file), isOverflow, additionalToHide)
}
@JvmStatic
@JvmOverloads
fun newInstance(
numberOfAllFiles: Int,
files: Collection<OCFile>,
isOverflow: Boolean,
@IdRes
additionalToHide: List<Int>? = null
): FileActionsBottomSheet {
return FileActionsBottomSheet().apply {
val argsBundle = bundleOf(
FileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles,
FileActionsViewModel.ARG_FILES to ArrayList<OCFile>(files),
FileActionsViewModel.ARG_IS_OVERFLOW to isOverflow
)
additionalToHide?.let {
argsBundle.putIntArray(FileActionsViewModel.ARG_ADDITIONAL_FILTER, additionalToHide.toIntArray())
}
arguments = argsBundle
}
}
}
}