FileActionsBottomSheet.kt 13 KB

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