FileActionsBottomSheet.kt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /*
  2. * Nextcloud - Android Client
  3. *
  4. * SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
  5. * SPDX-FileCopyrightText: 2022 Nextcloud GmbH
  6. * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
  7. */
  8. package com.nextcloud.ui.fileactions
  9. import android.content.Context
  10. import android.content.res.ColorStateList
  11. import android.graphics.Typeface
  12. import android.graphics.drawable.Drawable
  13. import android.os.Bundle
  14. import android.text.style.StyleSpan
  15. import android.view.LayoutInflater
  16. import android.view.View
  17. import android.view.ViewGroup
  18. import android.widget.Toast
  19. import androidx.annotation.IdRes
  20. import androidx.appcompat.content.res.AppCompatResources
  21. import androidx.core.os.bundleOf
  22. import androidx.core.view.isEmpty
  23. import androidx.core.view.isVisible
  24. import androidx.fragment.app.FragmentManager
  25. import androidx.fragment.app.setFragmentResult
  26. import androidx.lifecycle.LifecycleOwner
  27. import androidx.lifecycle.ViewModelProvider
  28. import com.google.android.material.bottomsheet.BottomSheetBehavior
  29. import com.google.android.material.bottomsheet.BottomSheetDialog
  30. import com.google.android.material.bottomsheet.BottomSheetDialogFragment
  31. import com.nextcloud.android.common.ui.theme.utils.ColorRole
  32. import com.nextcloud.client.account.CurrentAccountProvider
  33. import com.nextcloud.client.di.Injectable
  34. import com.nextcloud.client.di.ViewModelFactory
  35. import com.owncloud.android.R
  36. import com.owncloud.android.databinding.FileActionsBottomSheetBinding
  37. import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding
  38. import com.owncloud.android.datamodel.FileDataStorageManager
  39. import com.owncloud.android.datamodel.OCFile
  40. import com.owncloud.android.datamodel.SyncedFolderProvider
  41. import com.owncloud.android.datamodel.ThumbnailsCacheManager
  42. import com.owncloud.android.lib.resources.files.model.FileLockType
  43. import com.owncloud.android.ui.activity.ComponentsGetter
  44. import com.owncloud.android.utils.DisplayUtils
  45. import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener
  46. import com.owncloud.android.utils.theme.ViewThemeUtils
  47. import javax.inject.Inject
  48. class FileActionsBottomSheet : BottomSheetDialogFragment(), Injectable {
  49. @Inject
  50. lateinit var viewThemeUtils: ViewThemeUtils
  51. @Inject
  52. lateinit var vmFactory: ViewModelFactory
  53. @Inject
  54. lateinit var currentUserProvider: CurrentAccountProvider
  55. @Inject
  56. lateinit var storageManager: FileDataStorageManager
  57. @Inject
  58. lateinit var syncedFolderProvider: SyncedFolderProvider
  59. private lateinit var viewModel: FileActionsViewModel
  60. private var _binding: FileActionsBottomSheetBinding? = null
  61. val binding
  62. get() = _binding!!
  63. private lateinit var componentsGetter: ComponentsGetter
  64. private val thumbnailAsyncTasks = mutableListOf<ThumbnailsCacheManager.ThumbnailGenerationTask>()
  65. fun interface ResultListener {
  66. fun onResult(@IdRes actionId: Int)
  67. }
  68. override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
  69. viewModel = ViewModelProvider(this, vmFactory)[FileActionsViewModel::class.java]
  70. _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
  71. viewModel.uiState.observe(viewLifecycleOwner, this::handleState)
  72. viewModel.clickActionId.observe(viewLifecycleOwner) { id ->
  73. dispatchActionClick(id)
  74. }
  75. viewModel.load(requireArguments(), componentsGetter)
  76. val bottomSheetDialog = dialog as BottomSheetDialog
  77. bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
  78. bottomSheetDialog.behavior.skipCollapsed = true
  79. viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE)
  80. return binding.root
  81. }
  82. private fun handleState(state: FileActionsViewModel.UiState) {
  83. toggleLoadingOrContent(state)
  84. when (state) {
  85. is FileActionsViewModel.UiState.LoadedForSingleFile -> {
  86. loadFileThumbnail(state.titleFile)
  87. if (state.lockInfo != null) {
  88. displayLockInfo(state.lockInfo)
  89. }
  90. displayActions(state.actions)
  91. displayTitle(state.titleFile)
  92. }
  93. is FileActionsViewModel.UiState.LoadedForMultipleFiles -> {
  94. setMultipleFilesThumbnail()
  95. displayActions(state.actions)
  96. displayTitle(state.fileCount)
  97. }
  98. FileActionsViewModel.UiState.Loading -> {}
  99. FileActionsViewModel.UiState.Error -> {
  100. context?.let {
  101. Toast.makeText(it, R.string.error_file_actions, Toast.LENGTH_SHORT).show()
  102. }
  103. dismissAllowingStateLoss()
  104. }
  105. }
  106. }
  107. private fun loadFileThumbnail(titleFile: OCFile?) {
  108. titleFile?.let {
  109. DisplayUtils.setThumbnail(
  110. it,
  111. binding.thumbnailLayout.thumbnail,
  112. currentUserProvider.user,
  113. storageManager,
  114. thumbnailAsyncTasks,
  115. false,
  116. context,
  117. binding.thumbnailLayout.thumbnailShimmer,
  118. syncedFolderProvider.preferences,
  119. viewThemeUtils,
  120. syncedFolderProvider
  121. )
  122. }
  123. }
  124. private fun setMultipleFilesThumbnail() {
  125. context?.let {
  126. val drawable = viewThemeUtils.platform.tintDrawable(it, R.drawable.file_multiple, ColorRole.PRIMARY)
  127. binding.thumbnailLayout.thumbnail.setImageDrawable(drawable)
  128. }
  129. }
  130. override fun onDestroyView() {
  131. super.onDestroyView()
  132. _binding = null
  133. }
  134. override fun onAttach(context: Context) {
  135. super.onAttach(context)
  136. require(context is ComponentsGetter) {
  137. "Context is not a ComponentsGetter"
  138. }
  139. this.componentsGetter = context
  140. }
  141. fun setResultListener(
  142. fragmentManager: FragmentManager,
  143. lifecycleOwner: LifecycleOwner,
  144. listener: ResultListener
  145. ): FileActionsBottomSheet {
  146. fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result ->
  147. @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1)
  148. if (actionId != -1) {
  149. listener.onResult(actionId)
  150. }
  151. }
  152. return this
  153. }
  154. private fun toggleLoadingOrContent(state: FileActionsViewModel.UiState) {
  155. if (state is FileActionsViewModel.UiState.Loading) {
  156. binding.bottomSheetLoading.isVisible = true
  157. binding.bottomSheetHeader.isVisible = false
  158. viewThemeUtils.platform.colorCircularProgressBar(binding.bottomSheetLoading, ColorRole.PRIMARY)
  159. } else {
  160. binding.bottomSheetLoading.isVisible = false
  161. binding.bottomSheetHeader.isVisible = true
  162. }
  163. }
  164. private fun displayActions(actions: List<FileAction>) {
  165. if (binding.fileActionsList.isEmpty()) {
  166. actions.forEach { action ->
  167. val view = inflateActionView(action)
  168. binding.fileActionsList.addView(view)
  169. }
  170. }
  171. }
  172. private fun displayTitle(titleFile: OCFile?) {
  173. val decryptedFileName = titleFile?.decryptedFileName
  174. if (decryptedFileName != null) {
  175. decryptedFileName.let {
  176. binding.title.text = it
  177. }
  178. } else {
  179. binding.title.isVisible = false
  180. }
  181. }
  182. private fun displayLockInfo(lockInfo: FileActionsViewModel.LockInfo) {
  183. val view = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
  184. .apply {
  185. val textColor = ColorStateList.valueOf(resources.getColor(R.color.secondary_text_color, null))
  186. root.isClickable = false
  187. text.setTextColor(textColor)
  188. text.text = getLockedByText(lockInfo)
  189. if (lockInfo.lockedUntil != null) {
  190. textLine2.text = getLockedUntilText(lockInfo)
  191. textLine2.isVisible = true
  192. }
  193. if (lockInfo.lockType != FileLockType.COLLABORATIVE) {
  194. showLockAvatar(lockInfo)
  195. }
  196. }
  197. binding.fileActionsList.addView(view.root)
  198. }
  199. private fun FileActionsBottomSheetItemBinding.showLockAvatar(lockInfo: FileActionsViewModel.LockInfo) {
  200. val listener = object : AvatarGenerationListener {
  201. override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any?) {
  202. icon.setImageDrawable(avatarDrawable)
  203. }
  204. override fun shouldCallGeneratedCallback(tag: String?, callContext: Any?): Boolean {
  205. return false
  206. }
  207. }
  208. DisplayUtils.setAvatar(
  209. currentUserProvider.user,
  210. lockInfo.lockedBy,
  211. listener,
  212. resources.getDimension(R.dimen.list_item_avatar_icon_radius),
  213. resources,
  214. this,
  215. requireContext()
  216. )
  217. }
  218. private fun getLockedByText(lockInfo: FileActionsViewModel.LockInfo): CharSequence {
  219. val resource = when (lockInfo.lockType) {
  220. FileLockType.COLLABORATIVE -> R.string.locked_by_app
  221. else -> R.string.locked_by
  222. }
  223. return DisplayUtils.createTextWithSpan(
  224. getString(resource, lockInfo.lockedBy),
  225. lockInfo.lockedBy,
  226. StyleSpan(Typeface.BOLD)
  227. )
  228. }
  229. private fun getLockedUntilText(lockInfo: FileActionsViewModel.LockInfo): CharSequence {
  230. val relativeTimestamp = DisplayUtils.getRelativeTimestamp(context, lockInfo.lockedUntil!!, true)
  231. return getString(R.string.lock_expiration_info, relativeTimestamp)
  232. }
  233. private fun displayTitle(fileCount: Int) {
  234. binding.title.text = resources.getQuantityString(R.plurals.file_list__footer__file, fileCount, fileCount)
  235. }
  236. private fun inflateActionView(action: FileAction): View {
  237. val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
  238. .apply {
  239. root.setOnClickListener {
  240. viewModel.onClick(action)
  241. }
  242. text.setText(action.title)
  243. if (action.icon != null) {
  244. val drawable =
  245. viewThemeUtils.platform.tintDrawable(
  246. requireContext(),
  247. AppCompatResources.getDrawable(requireContext(), action.icon)!!
  248. )
  249. icon.setImageDrawable(drawable)
  250. }
  251. }
  252. return itemBinding.root
  253. }
  254. private fun dispatchActionClick(id: Int?) {
  255. if (id != null) {
  256. setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id))
  257. parentFragmentManager.clearFragmentResultListener(REQUEST_KEY)
  258. dismiss()
  259. }
  260. }
  261. companion object {
  262. private const val REQUEST_KEY = "REQUEST_KEY_ACTION"
  263. private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID"
  264. @JvmStatic
  265. @JvmOverloads
  266. fun newInstance(
  267. file: OCFile,
  268. isOverflow: Boolean,
  269. @IdRes
  270. additionalToHide: List<Int>? = null
  271. ): FileActionsBottomSheet {
  272. return newInstance(1, listOf(file), isOverflow, additionalToHide, true)
  273. }
  274. @JvmStatic
  275. @JvmOverloads
  276. fun newInstance(
  277. numberOfAllFiles: Int,
  278. files: Collection<OCFile>,
  279. isOverflow: Boolean,
  280. @IdRes
  281. additionalToHide: List<Int>? = null,
  282. inSingleFileFragment: Boolean = false
  283. ): FileActionsBottomSheet {
  284. return FileActionsBottomSheet().apply {
  285. val argsBundle = bundleOf(
  286. FileActionsViewModel.ARG_ALL_FILES_COUNT to numberOfAllFiles,
  287. FileActionsViewModel.ARG_FILES to ArrayList<OCFile>(files),
  288. FileActionsViewModel.ARG_IS_OVERFLOW to isOverflow,
  289. FileActionsViewModel.ARG_IN_SINGLE_FILE_FRAGMENT to inSingleFileFragment
  290. )
  291. additionalToHide?.let {
  292. argsBundle.putIntArray(FileActionsViewModel.ARG_ADDITIONAL_FILTER, additionalToHide.toIntArray())
  293. }
  294. arguments = argsBundle
  295. }
  296. }
  297. }
  298. }