From 2131e892ad705d48626c4e4312a0d7321674be76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Borcz?= Date: Mon, 28 Mar 2022 19:30:20 +0200 Subject: [PATCH] Add option to select multiple messages to delete (#1780) --- .../data/repositories/MessageRepository.kt | 29 +- .../modules/attendance/AttendanceFragment.kt | 12 +- .../ui/modules/message/MessageFragment.kt | 45 ++- .../ui/modules/message/MessagePresenter.kt | 14 +- .../ui/modules/message/MessageView.kt | 6 +- .../message/preview/MessagePreviewFragment.kt | 2 +- .../modules/message/tab/MessageTabAdapter.kt | 154 +++++----- .../modules/message/tab/MessageTabDataItem.kt | 21 +- .../modules/message/tab/MessageTabFragment.kt | 123 ++++++-- .../message/tab/MessageTabPresenter.kt | 274 ++++++++++++------ .../ui/modules/message/tab/MessageTabView.kt | 22 +- .../wulkanowy/ui/modules/more/MoreFragment.kt | 5 + .../res/drawable/ic_message_select_all.xml | 17 ++ .../res/drawable/ic_message_unselect_all.xml | 12 + .../res/layout/fragment_message_preview.xml | 4 +- app/src/main/res/layout/item_message.xml | 19 +- .../res/menu/action_menu_message_preview.xml | 2 +- ...excuse.xml => context_menu_attendance.xml} | 0 .../res/menu/context_menu_message_tab.xml | 18 ++ app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sk/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values/strings.xml | 16 +- 26 files changed, 569 insertions(+), 238 deletions(-) create mode 100644 app/src/main/res/drawable/ic_message_select_all.xml create mode 100644 app/src/main/res/drawable/ic_message_unselect_all.xml rename app/src/main/res/menu/{context_menu_excuse.xml => context_menu_attendance.xml} (100%) create mode 100644 app/src/main/res/menu/context_menu_message_tab.xml diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/MessageRepository.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/MessageRepository.kt index 8d6fd772a..05fb97657 100644 --- a/app/src/main/java/io/github/wulkanowy/data/repositories/MessageRepository.kt +++ b/app/src/main/java/io/github/wulkanowy/data/repositories/MessageRepository.kt @@ -103,7 +103,7 @@ class MessageRepository @Inject constructor( message: Message, markAsRead: Boolean = false, ): Flow> = networkBoundResource( - isResultEmpty = { it == null }, + isResultEmpty = { it?.message?.content.isNullOrBlank() }, shouldFetch = { checkNotNull(it) { "This message no longer exist!" } Timber.d("Message content in db empty: ${it.message.content.isEmpty()}") @@ -151,20 +151,27 @@ class MessageRepository @Inject constructor( recipients = recipients.mapFromEntities() ) - suspend fun deleteMessage(student: Student, message: Message) { - val isDeleted = sdk.init(student).deleteMessages( - messages = listOf(message.messageId), message.folderId - ) + suspend fun deleteMessages(student: Student, messages: List) { + val folderId = messages.first().folderId + val isDeleted = sdk.init(student) + .deleteMessages(messages = messages.map { it.messageId }, folderId = folderId) - if (message.folderId != MessageFolder.TRASHED.id && isDeleted) { - val deletedMessage = message.copy(folderId = MessageFolder.TRASHED.id).apply { - id = message.id - content = message.content + if (folderId != MessageFolder.TRASHED.id && isDeleted) { + val deletedMessages = messages.map { + it.copy(folderId = MessageFolder.TRASHED.id) + .apply { + id = it.id + content = it.content + } } - messagesDb.updateAll(listOf(deletedMessage)) - } else messagesDb.deleteAll(listOf(message)) + + messagesDb.updateAll(deletedMessages) + } else messagesDb.deleteAll(messages) } + suspend fun deleteMessage(student: Student, message: Message) = + deleteMessages(student, listOf(message)) + var draftMessage: MessageDraft? get() = sharedPrefProvider.getString(context.getString(R.string.pref_key_message_send_draft)) ?.let { json.decodeFromString(it) } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/AttendanceFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/AttendanceFragment.kt index 84af1ca32..6354b5e04 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/AttendanceFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/AttendanceFragment.kt @@ -2,14 +2,8 @@ package io.github.wulkanowy.ui.modules.attendance import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE +import android.view.* +import android.view.View.* import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ActionMode import androidx.core.view.isVisible @@ -68,7 +62,7 @@ class AttendanceFragment : BaseFragment(R.layout.frag private val actionModeCallback = object : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { val inflater = mode.menuInflater - inflater.inflate(R.menu.context_menu_excuse, menu) + inflater.inflate(R.menu.context_menu_attendance, menu) return true } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageFragment.kt index acf3133d9..4607793c9 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageFragment.kt @@ -4,12 +4,14 @@ import android.os.Bundle import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updateMargins import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import io.github.wulkanowy.R -import io.github.wulkanowy.data.enums.MessageFolder.RECEIVED -import io.github.wulkanowy.data.enums.MessageFolder.SENT -import io.github.wulkanowy.data.enums.MessageFolder.TRASHED +import io.github.wulkanowy.data.enums.MessageFolder.* import io.github.wulkanowy.databinding.FragmentMessageBinding import io.github.wulkanowy.ui.base.BaseFragment import io.github.wulkanowy.ui.base.BaseFragmentPagerAdapter @@ -78,7 +80,6 @@ class MessageFragment : BaseFragment(R.layout.fragment_m } binding.messageTabLayout.elevation = requireContext().dpToPx(4f) - binding.openSendMessageButton.setOnClickListener { presenter.onSendMessageButtonClicked() } } @@ -93,12 +94,37 @@ class MessageFragment : BaseFragment(R.layout.fragment_m binding.messageProgress.visibility = if (show) VISIBLE else INVISIBLE } + override fun showNewMessage(show: Boolean) { + binding.openSendMessageButton.run { + if (show) show() else hide() + } + } + + override fun showTabLayout(show: Boolean) { + binding.messageTabLayout.isVisible = show + + with(binding.messageViewPager) { + isUserInputEnabled = show + updateLayoutParams { + updateMargins(top = if (show) requireContext().dpToPx(48f).toInt() else 0) + } + } + } + + fun onChildFragmentShowActionMode(show: Boolean) { + presenter.onChildViewShowActionMode(show) + } + fun onChildFragmentLoaded() { presenter.onChildViewLoaded() } - override fun notifyChildMessageDeleted(tabId: Int) { - (pagerAdapter.getFragmentInstance(tabId) as? MessageTabFragment)?.onParentDeleteMessage() + fun onChildFragmentShowNewMessage(show: Boolean) { + presenter.onChildViewShowNewMessage(show) + } + + fun onFragmentChanged() { + presenter.onFragmentChanged() } override fun notifyChildLoadData(index: Int, forceRefresh: Boolean) { @@ -106,6 +132,13 @@ class MessageFragment : BaseFragment(R.layout.fragment_m ?.onParentLoadData(forceRefresh) } + override fun notifyChildrenFinishActionMode() { + repeat(3) { + (pagerAdapter.getFragmentInstance(it) as? MessageTabFragment) + ?.onParentFinishActionMode() + } + } + override fun openSendMessage() { context?.let { it.startActivity(SendMessageActivity.getStartIntent(it)) } } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessagePresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessagePresenter.kt index 9e19517bd..68bdc4b7c 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessagePresenter.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessagePresenter.kt @@ -3,7 +3,6 @@ package io.github.wulkanowy.ui.modules.message import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.ErrorHandler -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -24,6 +23,7 @@ class MessagePresenter @Inject constructor( fun onPageSelected(index: Int) { loadChild(index) + view?.notifyChildrenFinishActionMode() } private fun loadData() { @@ -35,6 +35,10 @@ class MessagePresenter @Inject constructor( view?.notifyChildLoadData(index, forceRefresh) } + fun onFragmentChanged() { + view?.notifyChildrenFinishActionMode() + } + fun onChildViewLoaded() { view?.apply { showContent(true) @@ -42,6 +46,14 @@ class MessagePresenter @Inject constructor( } } + fun onChildViewShowNewMessage(show: Boolean) { + view?.showNewMessage(show) + } + + fun onChildViewShowActionMode(show: Boolean) { + view?.showTabLayout(!show) + } + fun onSendMessageButtonClicked() { view?.openSendMessage() } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageView.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageView.kt index 2aa4d78ec..e0cc5098c 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageView.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/MessageView.kt @@ -12,9 +12,13 @@ interface MessageView : BaseView { fun showProgress(show: Boolean) + fun showNewMessage(show: Boolean) + + fun showTabLayout(show: Boolean) + fun notifyChildLoadData(index: Int, forceRefresh: Boolean) - fun notifyChildMessageDeleted(tabId: Int) + fun notifyChildrenFinishActionMode() fun openSendMessage() } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/preview/MessagePreviewFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/preview/MessagePreviewFragment.kt index e1cc2e374..860ecc571 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/preview/MessagePreviewFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/preview/MessagePreviewFragment.kt @@ -142,7 +142,7 @@ class MessagePreviewFragment : } override fun setNotDeletedOptionsLabels() { - menuDeleteButton?.setTitle(R.string.message_move_to_bin) + menuDeleteButton?.setTitle(R.string.message_move_to_trash) } override fun showErrorView(show: Boolean) { diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabAdapter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabAdapter.kt index 571cc6d55..af0923b94 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabAdapter.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabAdapter.kt @@ -2,15 +2,12 @@ package io.github.wulkanowy.ui.modules.message.tab import android.graphics.Typeface import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_POSITION import io.github.wulkanowy.R -import io.github.wulkanowy.data.db.entities.Message import io.github.wulkanowy.data.enums.MessageFolder import io.github.wulkanowy.databinding.ItemMessageBinding import io.github.wulkanowy.databinding.ItemMessageChipsBinding @@ -20,118 +17,141 @@ import javax.inject.Inject class MessageTabAdapter @Inject constructor() : RecyclerView.Adapter() { - enum class ViewType { HEADER, ITEM } + var onItemClickListener: (MessageTabDataItem.MessageItem, position: Int) -> Unit = { _, _ -> } - var onItemClickListener: (Message, position: Int) -> Unit = { _, _ -> } - var onHeaderClickListener: (chip: CompoundButton, isChecked: Boolean) -> Unit = { _, _ -> } + var onLongItemClickListener: (MessageTabDataItem.MessageItem) -> Unit = {} + + var onHeaderClickListener: (CompoundButton, Boolean) -> Unit = { _, _ -> } var onChangesDetectedListener = {} private var items = mutableListOf() - private var onlyUnread: Boolean? = null - private var onlyWithAttachments = false - fun setDataItems( - data: List, - onlyUnread: Boolean?, - onlyWithAttachments: Boolean - ) { - if (items.size != data.size) onChangesDetectedListener() + fun submitData(data: List) { + val originalMessagesSize = items.count { it.viewType == MessageItemViewType.MESSAGE } + val newMessagesSize = data.count { it.viewType == MessageItemViewType.MESSAGE } + + if (originalMessagesSize != newMessagesSize) onChangesDetectedListener() + val diffResult = DiffUtil.calculateDiff(MessageTabDiffUtil(items, data)) items = data.toMutableList() - this.onlyUnread = onlyUnread - this.onlyWithAttachments = onlyWithAttachments + diffResult.dispatchUpdatesTo(this) } - override fun getItemViewType(position: Int): Int { - return when (position) { - 0 -> ViewType.HEADER.ordinal - else -> ViewType.ITEM.ordinal - } - } + override fun getItemViewType(position: Int) = items[position].viewType.ordinal override fun getItemCount() = items.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - ViewType.ITEM.ordinal -> ItemViewHolder( + + return when (MessageItemViewType.values()[viewType]) { + MessageItemViewType.MESSAGE -> ItemViewHolder( ItemMessageBinding.inflate(inflater, parent, false) ) - ViewType.HEADER.ordinal -> HeaderViewHolder( + MessageItemViewType.FILTERS -> HeaderViewHolder( ItemMessageChipsBinding.inflate(inflater, parent, false) ) - else -> throw IllegalStateException() } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { - is ItemViewHolder -> { - val item = (items[position] as MessageTabDataItem.MessageItem).message + is ItemViewHolder -> bindItemViewHolder(holder, position) + is HeaderViewHolder -> bindHeaderViewHolder(holder, position) + } + } - with(holder.binding) { - val style = if (item.unread) Typeface.BOLD else Typeface.NORMAL + private fun bindHeaderViewHolder(holder: HeaderViewHolder, position: Int) { + val item = items[position] as MessageTabDataItem.FilterHeader - messageItemAuthor.run { - text = - if (item.folderId == MessageFolder.SENT.id) item.recipient else item.sender - setTypeface(null, style) - } - messageItemSubject.run { - text = - if (item.subject.isNotBlank()) item.subject else context.getString(R.string.message_no_subject) - setTypeface(null, style) - } - messageItemDate.run { - text = item.date.toFormattedString() - setTypeface(null, style) - } - messageItemAttachmentIcon.visibility = - if (item.hasAttachments) View.VISIBLE else View.GONE + with(holder.binding) { + if (item.onlyUnread == null) { + chipUnread.isVisible = false + } else { + chipUnread.isVisible = true + chipUnread.isChecked = item.onlyUnread + chipUnread.setOnCheckedChangeListener(onHeaderClickListener) + } + chipUnread.isEnabled = item.isEnabled + chipAttachments.isEnabled = item.isEnabled + chipAttachments.isChecked = item.onlyWithAttachments + chipAttachments.setOnCheckedChangeListener(onHeaderClickListener) + } + } - root.setOnClickListener { - holder.bindingAdapterPosition.let { - if (it != NO_POSITION) onItemClickListener(item, it) - } + private fun bindItemViewHolder(holder: ItemViewHolder, position: Int) { + val item = (items[position] as MessageTabDataItem.MessageItem) + val message = item.message + + with(holder.binding) { + val style = if (message.unread) Typeface.BOLD else Typeface.NORMAL + + messageItemAuthor.run { + text = if (message.folderId == MessageFolder.SENT.id) { + message.recipient + } else { + message.sender + } + setTypeface(null, style) + } + messageItemSubject.run { + text = message.subject.ifBlank { context.getString(R.string.message_no_subject) } + setTypeface(null, style) + } + messageItemDate.run { + text = message.date.toFormattedString() + setTypeface(null, style) + } + messageItemAttachmentIcon.isVisible = message.hasAttachments + + root.setOnClickListener { + holder.bindingAdapterPosition.let { + if (it != RecyclerView.NO_POSITION) { + onItemClickListener(item, it) } } } - is HeaderViewHolder -> { - with(holder.binding) { - if (onlyUnread == null) chipUnread.isVisible = false - else { - chipUnread.isVisible = true - chipUnread.isChecked = onlyUnread!! - chipUnread.setOnCheckedChangeListener(onHeaderClickListener) - } - chipAttachments.isChecked = onlyWithAttachments - chipAttachments.setOnCheckedChangeListener(onHeaderClickListener) - } + + root.setOnLongClickListener { + onLongItemClickListener(item) + return@setOnLongClickListener true + } + + with(messageItemCheckbox) { + isChecked = item.isSelected + isVisible = item.isActionMode } } } class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root) + class HeaderViewHolder(val binding: ItemMessageChipsBinding) : RecyclerView.ViewHolder(binding.root) private class MessageTabDiffUtil( private val old: List, private val new: List - ) : - DiffUtil.Callback() { + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = old.size override fun getNewListSize(): Int = new.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return old[oldItemPosition].id == new[newItemPosition].id + val oldItem = old[oldItemPosition] + val newItem = new[newItemPosition] + + return if (oldItem is MessageTabDataItem.MessageItem && newItem is MessageTabDataItem.MessageItem) { + oldItem.message.id == newItem.message.id + } else { + oldItem.viewType == newItem.viewType + } } - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return old[oldItemPosition] == new[newItemPosition] - } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + old[oldItemPosition] == new[newItemPosition] } } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabDataItem.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabDataItem.kt index 4f51a936f..634dfc0e7 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabDataItem.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabDataItem.kt @@ -2,14 +2,19 @@ package io.github.wulkanowy.ui.modules.message.tab import io.github.wulkanowy.data.db.entities.Message -sealed class MessageTabDataItem { - data class MessageItem(val message: Message) : MessageTabDataItem() { - override val id = message.id - } +sealed class MessageTabDataItem(val viewType: MessageItemViewType) { - object Header : MessageTabDataItem() { - override val id = Long.MIN_VALUE - } + data class MessageItem( + val message: Message, + val isSelected: Boolean, + val isActionMode: Boolean + ) : MessageTabDataItem(MessageItemViewType.MESSAGE) - abstract val id: Long + data class FilterHeader( + val onlyUnread: Boolean?, + val onlyWithAttachments: Boolean, + val isEnabled: Boolean + ) : MessageTabDataItem(MessageItemViewType.FILTERS) } + +enum class MessageItemViewType { FILTERS, MESSAGE } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabFragment.kt index 54ee74eb1..654b0e226 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabFragment.kt @@ -3,12 +3,13 @@ package io.github.wulkanowy.ui.modules.message.tab import android.os.Bundle import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem import android.view.View -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE +import android.view.View.* import android.widget.CompoundButton +import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView +import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import io.github.wulkanowy.R @@ -20,7 +21,9 @@ import io.github.wulkanowy.ui.modules.main.MainActivity import io.github.wulkanowy.ui.modules.message.MessageFragment import io.github.wulkanowy.ui.modules.message.preview.MessagePreviewFragment import io.github.wulkanowy.ui.widgets.DividerItemDecoration +import io.github.wulkanowy.utils.dpToPx import io.github.wulkanowy.utils.getThemeAttrColor +import io.github.wulkanowy.utils.hideSoftInput import javax.inject.Inject @AndroidEntryPoint @@ -31,9 +34,10 @@ class MessageTabFragment : BaseFragment(R.layout.frag lateinit var presenter: MessageTabPresenter @Inject - lateinit var tabAdapter: MessageTabAdapter + lateinit var messageTabAdapter: MessageTabAdapter companion object { + const val MESSAGE_TAB_FOLDER_ID = "message_tab_folder_id" fun newInstance(folder: MessageFolder): MessageTabFragment { @@ -46,11 +50,38 @@ class MessageTabFragment : BaseFragment(R.layout.frag } override val isViewEmpty - get() = tabAdapter.itemCount == 0 + get() = messageTabAdapter.itemCount == 0 - override var onlyUnread: Boolean? = false + private var actionMode: ActionMode? = null - override var onlyWithAttachments = false + private val actionModeCallback = object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + val inflater = mode.menuInflater + inflater.inflate(R.menu.context_menu_message_tab, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + if (presenter.folder == MessageFolder.TRASHED) { + val menuItem = menu.findItem(R.id.messageTabContextMenuDelete) + menuItem.setTitle(R.string.message_delete_forever) + } + return presenter.onPrepareActionMode() + } + + override fun onDestroyActionMode(mode: ActionMode) { + presenter.onDestroyActionMode() + actionMode = null + } + + override fun onActionItemClicked(mode: ActionMode, menu: MenuItem): Boolean { + when (menu.itemId) { + R.id.messageTabContextMenuDelete -> presenter.onActionModeSelectDelete() + R.id.messageTabContextMenuSelectAll -> presenter.onActionModeSelectCheckAll() + } + return true + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,24 +100,25 @@ class MessageTabFragment : BaseFragment(R.layout.frag } override fun initView() { - with(tabAdapter) { + with(messageTabAdapter) { onItemClickListener = presenter::onMessageItemSelected + onLongItemClickListener = presenter::onMessageItemLongSelected onHeaderClickListener = ::onChipChecked onChangesDetectedListener = ::resetListPosition } with(binding.messageTabRecycler) { layoutManager = LinearLayoutManager(context) - adapter = tabAdapter + adapter = messageTabAdapter addItemDecoration(DividerItemDecoration(context, false)) + itemAnimator = null } + with(binding) { messageTabSwipe.setOnRefreshListener(presenter::onSwipeRefresh) messageTabSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary)) messageTabSwipe.setProgressBackgroundColorSchemeColor( - requireContext().getThemeAttrColor( - R.attr.colorSwipeRefresh - ) + requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh) ) messageTabErrorRetry.setOnClickListener { presenter.onRetry() } messageTabErrorDetails.setOnClickListener { presenter.onDetailsClick() } @@ -109,9 +141,28 @@ class MessageTabFragment : BaseFragment(R.layout.frag }) } - override fun updateData(data: List, hide: Boolean) { - if (hide) onlyUnread = null - tabAdapter.setDataItems(data, onlyUnread, onlyWithAttachments) + override fun updateData(data: List) { + messageTabAdapter.submitData(data) + } + + override fun updateActionModeTitle(selectedMessagesSize: Int) { + actionMode?.title = resources.getQuantityString( + R.plurals.message_selected_messages_count, + selectedMessagesSize, + selectedMessagesSize + ) + } + + override fun updateSelectAllMenu(isAllSelected: Boolean) { + val menuItem = actionMode?.menu?.findItem(R.id.messageTabContextMenuSelectAll) ?: return + + if (isAllSelected) { + menuItem.setTitle(R.string.message_unselect_all) + menuItem.setIcon(R.drawable.ic_message_unselect_all) + } else { + menuItem.setTitle(R.string.message_select_all) + menuItem.setIcon(R.drawable.ic_message_select_all) + } } override fun showProgress(show: Boolean) { @@ -146,6 +197,14 @@ class MessageTabFragment : BaseFragment(R.layout.frag binding.messageTabSwipe.isRefreshing = show } + override fun showMessagesDeleted() { + showMessage(getString(R.string.message_messages_deleted)) + } + + override fun notifyParentShowNewMessage(show: Boolean) { + (parentFragment as? MessageFragment)?.onChildFragmentShowNewMessage(show) + } + override fun openMessage(message: Message) { (activity as? MainActivity)?.pushView(MessagePreviewFragment.newInstance(message)) } @@ -154,12 +213,16 @@ class MessageTabFragment : BaseFragment(R.layout.frag (parentFragment as? MessageFragment)?.onChildFragmentLoaded() } - fun onParentLoadData( - forceRefresh: Boolean, - onlyUnread: Boolean? = this.onlyUnread, - onlyWithAttachments: Boolean = this.onlyWithAttachments - ) { - presenter.onParentViewLoadData(forceRefresh, onlyUnread, onlyWithAttachments) + override fun notifyParentShowActionMode(show: Boolean) { + (parentFragment as? MessageFragment)?.onChildFragmentShowActionMode(show) + } + + fun onParentLoadData(forceRefresh: Boolean) { + presenter.onParentViewLoadData(forceRefresh) + } + + fun onParentFinishActionMode() { + presenter.onParentFinishActionMode() } private fun onChipChecked(chip: CompoundButton, isChecked: Boolean) { @@ -169,8 +232,22 @@ class MessageTabFragment : BaseFragment(R.layout.frag } } - fun onParentDeleteMessage() { - presenter.onDeleteMessage() + override fun showActionMode(show: Boolean) { + if (show) { + actionMode = (activity as MainActivity?)?.startSupportActionMode(actionModeCallback) + } else { + actionMode?.finish() + } + } + + override fun showRecyclerBottomPadding(show: Boolean) { + binding.messageTabRecycler.updatePadding( + bottom = if (show) requireContext().dpToPx(64f).toInt() else 0 + ) + } + + override fun hideKeyboard() { + activity?.hideSoftInput() } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabPresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabPresenter.kt index 57055a644..870b6433e 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabPresenter.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabPresenter.kt @@ -12,7 +12,10 @@ import io.github.wulkanowy.utils.AnalyticsHelper import io.github.wulkanowy.utils.toFormattedString import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import me.xdrop.fuzzywuzzy.FuzzySearch import timber.log.Timber @@ -37,6 +40,14 @@ class MessageTabPresenter @Inject constructor( private val searchChannel = Channel() + private val messagesToDelete = mutableSetOf() + + private var onlyUnread: Boolean? = false + + private var onlyWithAttachments = false + + private var isActionMode = false + fun onAttachView(view: MessageTabView, folder: MessageFolder) { super.onAttachView(view) view.initView() @@ -47,14 +58,14 @@ class MessageTabPresenter @Inject constructor( fun onSwipeRefresh() { Timber.i("Force refreshing the $folder message") - view?.run { onParentViewLoadData(true, onlyUnread, onlyWithAttachments) } + view?.run { loadData(true) } } fun onRetry() { view?.run { showErrorView(false) showProgress(true) - loadData(true, onlyUnread == true, onlyWithAttachments) + loadData(true) } } @@ -62,42 +73,135 @@ class MessageTabPresenter @Inject constructor( view?.showErrorDetailsDialog(lastError) } - fun onDeleteMessage() { - view?.run { loadData(true, onlyUnread == true, onlyWithAttachments) } + fun onParentViewLoadData(forceRefresh: Boolean) { + loadData(forceRefresh) } - fun onParentViewLoadData( - forceRefresh: Boolean, - onlyUnread: Boolean? = view?.onlyUnread, - onlyWithAttachments: Boolean = view?.onlyWithAttachments == true - ) { - loadData(forceRefresh, onlyUnread == true, onlyWithAttachments) + fun onParentFinishActionMode() { + view?.showActionMode(false) } - fun onMessageItemSelected(message: Message, position: Int) { - Timber.i("Select message ${message.id} item (position: $position)") - view?.openMessage(message) + fun onDestroyActionMode() { + isActionMode = false + messagesToDelete.clear() + updateDataInView() + + view?.run { + enableSwipe(true) + notifyParentShowNewMessage(true) + notifyParentShowActionMode(false) + showRecyclerBottomPadding(true) + } + } + + fun onPrepareActionMode(): Boolean { + isActionMode = true + messagesToDelete.clear() + updateDataInView() + + view?.apply { + enableSwipe(false) + notifyParentShowNewMessage(false) + notifyParentShowActionMode(true) + showRecyclerBottomPadding(false) + hideKeyboard() + } + return true + } + + fun onActionModeSelectDelete() { + Timber.i("Delete ${messagesToDelete.size} messages)") + val messageList = messagesToDelete.toList() + + presenterScope.launch { + view?.run { + showProgress(true) + showContent(false) + showActionMode(false) + } + + runCatching { + val student = studentRepository.getCurrentStudent(true) + messageRepository.deleteMessages(student, messageList) + } + .onFailure(errorHandler::dispatch) + .onSuccess { view?.showMessagesDeleted() } + } + } + + fun onActionModeSelectCheckAll() { + val messagesToSelect = getFilteredData() + val isAllSelected = messagesToDelete.containsAll(messagesToSelect) + + if (isAllSelected) { + messagesToDelete.clear() + view?.showActionMode(false) + } else { + messagesToDelete.addAll(messagesToSelect) + updateDataInView() + } + + view?.run { + updateSelectAllMenu(!isAllSelected) + updateActionModeTitle(messagesToDelete.size) + } + } + + fun onMessageItemLongSelected(messageItem: MessageTabDataItem.MessageItem) { + if (!isActionMode) { + view?.showActionMode(true) + + messagesToDelete.add(messageItem.message) + + view?.updateActionModeTitle(messagesToDelete.size) + updateDataInView() + } + } + + fun onMessageItemSelected(messageItem: MessageTabDataItem.MessageItem, position: Int) { + Timber.i("Select message ${messageItem.message.id} item (position: $position)") + + if (!isActionMode) { + view?.run { + showActionMode(false) + openMessage(messageItem.message) + } + } else { + if (!messageItem.isSelected) { + messagesToDelete.add(messageItem.message) + } else { + messagesToDelete.remove(messageItem.message) + } + + if (messagesToDelete.isEmpty()) { + view?.showActionMode(false) + } + + val filteredData = getFilteredData() + + view?.run { + updateActionModeTitle(messagesToDelete.size) + updateSelectAllMenu(messagesToDelete.containsAll(filteredData)) + } + updateDataInView() + } } fun onUnreadFilterSelected(isChecked: Boolean) { view?.run { onlyUnread = isChecked - onParentViewLoadData(false, onlyUnread, onlyWithAttachments) + loadData(false) } } fun onAttachmentsFilterSelected(isChecked: Boolean) { view?.run { onlyWithAttachments = isChecked - onParentViewLoadData(false, onlyUnread, onlyWithAttachments) + loadData(false) } } - private fun loadData( - forceRefresh: Boolean, - onlyUnread: Boolean, - onlyWithAttachments: Boolean - ) { + private fun loadData(forceRefresh: Boolean) { Timber.i("Loading $folder message data started") flatResourceFlow { @@ -106,54 +210,29 @@ class MessageTabPresenter @Inject constructor( messageRepository.getMessages(student, semester, folder, forceRefresh) } .logResourceStatus("load $folder message") - .onEach { - when (it) { - is Resource.Intermediate -> { - if (it.data.isNotEmpty()) { - view?.run { - enableSwipe(true) - showErrorView(false) - showRefresh(true) - showProgress(false) - showContent(true) - messages = it.data - val filteredData = getFilteredData( - lastSearchQuery, - onlyUnread, - onlyWithAttachments - ) - val messageItems = filteredData.map { message -> - MessageTabDataItem.MessageItem(message) - } - val messageItemsWithHeader = - listOf(MessageTabDataItem.Header) + messageItems + .onResourceData { + messages = it - updateData( - messageItemsWithHeader, - folder.id == MessageFolder.SENT.id - ) - notifyParentDataLoaded() - } - } - } - is Resource.Success -> { - messages = it.data - updateData( - getFilteredData( - lastSearchQuery, - onlyUnread, - onlyWithAttachments - ) - ) - analytics.logEvent( - "load_data", - "type" to "messages", - "items" to it.data.size, - "folder" to folder.name - ) - } - else -> {} + val filteredData = getFilteredData() + + view?.run { + enableSwipe(true) + showErrorView(false) + showProgress(false) + showContent(true) + showEmpty(filteredData.isEmpty()) } + + updateDataInView() + } + .onResourceIntermediate { view?.showRefresh(true) } + .onResourceSuccess { + analytics.logEvent( + "load_data", + "type" to "messages", + "items" to it.size, + "folder" to folder.name + ) } .onResourceNotLoading { view?.run { @@ -196,56 +275,71 @@ class MessageTabPresenter @Inject constructor( .debounce(250) .map { query -> lastSearchQuery = query - val isOnlyUnread = view?.onlyUnread == true - val isOnlyWithAttachments = view?.onlyWithAttachments == true - getFilteredData(query, isOnlyUnread, isOnlyWithAttachments) + + getFilteredData() } .catch { Timber.e(it) } .collect { Timber.d("Applying filter. Full list: ${messages.size}, filtered: ${it.size}") - updateData(it) + + view?.run { + showEmpty(it.isEmpty()) + showContent(true) + showErrorView(false) + } + + updateDataInView() view?.resetListPosition() } } } - private fun getFilteredData( - query: String, - onlyUnread: Boolean = false, - onlyWithAttachments: Boolean = false - ): List { - if (query.trim().isEmpty()) { + private fun getFilteredData(): List { + if (lastSearchQuery.trim().isEmpty()) { val sortedMessages = messages.sortedByDescending { it.date } return when { - onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments } - onlyUnread -> sortedMessages.filter { it.unread == onlyUnread } + (onlyUnread == true) && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments } + (onlyUnread == true) -> sortedMessages.filter { it.unread == onlyUnread } onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments } else -> sortedMessages } } else { val sortedMessages = messages - .map { it to calculateMatchRatio(it, query) } + .map { it to calculateMatchRatio(it, lastSearchQuery) } .sortedWith(compareBy> { -it.second }.thenByDescending { it.first.date }) .filter { it.second > 6000 } .map { it.first } return when { - onlyUnread && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments } - onlyUnread -> sortedMessages.filter { it.unread == onlyUnread } + (onlyUnread == true) && onlyWithAttachments -> sortedMessages.filter { it.unread == onlyUnread && it.hasAttachments == onlyWithAttachments } + (onlyUnread == true) -> sortedMessages.filter { it.unread == onlyUnread } onlyWithAttachments -> sortedMessages.filter { it.hasAttachments == onlyWithAttachments } else -> sortedMessages } } } - private fun updateData(data: List) { - view?.run { - showEmpty(data.isEmpty()) - showContent(true) - showErrorView(false) - val newItems = - listOf(MessageTabDataItem.Header) + data.map { MessageTabDataItem.MessageItem(it) } - updateData(newItems, folder.id == MessageFolder.SENT.id) + private fun updateDataInView() { + val data = getFilteredData() + + val list = buildList { + add( + MessageTabDataItem.FilterHeader( + onlyUnread = onlyUnread.takeIf { folder != MessageFolder.SENT }, + onlyWithAttachments = onlyWithAttachments, + isEnabled = !isActionMode + ) + ) + + addAll(data.map { message -> + MessageTabDataItem.MessageItem( + message = message, + isSelected = messagesToDelete.any { it.id == message.id }, + isActionMode = isActionMode + ) + }) } + + view?.updateData(list) } private fun calculateMatchRatio(message: Message, query: String): Int { diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabView.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabView.kt index a856da3bc..bfa43b209 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabView.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/message/tab/MessageTabView.kt @@ -7,15 +7,15 @@ interface MessageTabView : BaseView { val isViewEmpty: Boolean - var onlyUnread: Boolean? - - var onlyWithAttachments: Boolean - fun initView() fun resetListPosition() - fun updateData(data: List, hide: Boolean) + fun updateData(data: List) + + fun updateActionModeTitle(selectedMessagesSize: Int) + + fun updateSelectAllMenu(isAllSelected: Boolean) fun showProgress(show: Boolean) @@ -25,8 +25,12 @@ interface MessageTabView : BaseView { fun showEmpty(show: Boolean) + fun showMessagesDeleted() + fun showErrorView(show: Boolean) + fun notifyParentShowNewMessage(show: Boolean) + fun setErrorDetails(message: String) fun showRefresh(show: Boolean) @@ -34,4 +38,12 @@ interface MessageTabView : BaseView { fun openMessage(message: Message) fun notifyParentDataLoaded() + + fun notifyParentShowActionMode(show: Boolean) + + fun hideKeyboard() + + fun showActionMode(show: Boolean) + + fun showRecyclerBottomPadding(show: Boolean) } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/more/MoreFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/more/MoreFragment.kt index 145b12a35..df55abc9c 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/more/MoreFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/more/MoreFragment.kt @@ -89,6 +89,11 @@ class MoreFragment : BaseFragment(R.layout.fragment_more), if (::presenter.isInitialized) presenter.onViewReselected() } + override fun onFragmentChanged() { + (parentFragmentManager.fragments.find { it is MessageFragment } as MessageFragment?) + ?.onFragmentChanged() + } + override fun updateData(data: List>) { with(moreAdapter) { items = data diff --git a/app/src/main/res/drawable/ic_message_select_all.xml b/app/src/main/res/drawable/ic_message_select_all.xml new file mode 100644 index 000000000..eab195d94 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_select_all.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_message_unselect_all.xml b/app/src/main/res/drawable/ic_message_unselect_all.xml new file mode 100644 index 000000000..c388522e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_unselect_all.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/fragment_message_preview.xml b/app/src/main/res/layout/fragment_message_preview.xml index 4b15cf108..5980d820b 100644 --- a/app/src/main/res/layout/fragment_message_preview.xml +++ b/app/src/main/res/layout/fragment_message_preview.xml @@ -9,8 +9,10 @@ android:id="@+id/messagePreviewRecycler" android:layout_width="match_parent" android:layout_height="match_parent" + android:visibility="gone" tools:itemCount="1" - tools:listitem="@layout/item_message_preview" /> + tools:listitem="@layout/item_message_preview" + tools:visibility="visible" /> + + @@ -45,6 +57,7 @@ android:textColor="?android:textColorSecondary" android:textSize="12sp" app:layout_constraintEnd_toStartOf="@id/messageItemAttachmentIcon" + app:layout_constraintStart_toEndOf="@id/messageItemCheckbox" app:layout_constraintStart_toStartOf="@id/messageItemAuthor" app:layout_constraintTop_toBottomOf="@+id/messageItemAuthor" app:layout_goneMarginEnd="0dp" diff --git a/app/src/main/res/menu/action_menu_message_preview.xml b/app/src/main/res/menu/action_menu_message_preview.xml index 4c1332e10..5011e2356 100644 --- a/app/src/main/res/menu/action_menu_message_preview.xml +++ b/app/src/main/res/menu/action_menu_message_preview.xml @@ -19,7 +19,7 @@ android:id="@+id/messagePreviewMenuDelete" android:icon="@drawable/ic_menu_message_delete" android:orderInCategory="1" - android:title="@string/message_delete" + android:title="@string/message_move_to_trash" app:iconTint="@color/material_on_surface_emphasis_medium" app:showAsAction="ifRoom" /> + + + + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e7c2da332..169beed07 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -287,7 +287,7 @@ Odpověď Poslat dále Odstranit - Přesunout do koše + Přesunout do koše Odstranit natrvalo Zpráva byla úspěšně odstraněna Sdílet diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 68c371d51..a97019446 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -253,7 +253,7 @@ Antwort Weiterleiten Löschen - In den Korb wandern + In den Korb wandern Dauerhaft löschen Nachricht erfolgreich gelöscht Teilen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index da127fe2d..85018247f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -287,7 +287,7 @@ Odpowiedz Prześlij dalej Usuń - Przenieś do kosza + Przenieś do kosza Usuń trwale Wiadomość usunięta pomyślnie Udostępnij diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e6e9834f5..1b3553f1e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -287,7 +287,7 @@ Ответ Переслать Удалить - Перенести в корзину + Перенести в корзину Удалить навсегда Сообщение успешно удалено Поделиться diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ba701eafd..d33b86654 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -287,7 +287,7 @@ Odpoveď Poslať ďalej Odstrániť - Presunúť do koša + Presunúť do koša Odstrániť natrvalo Správa bola úspešne odstránená Zdieľať diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7bcffe01b..ec2427c95 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -287,7 +287,7 @@ Відповісти Переслати Видалити - Перемістити у кошик + Перемістити у кошик Видалити назавжди Повідомлення було успішно видалено Поділіться diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee702251e..2763f00de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,11 +277,12 @@ No messages From: To: - Date: %s + Date: %1$s Reply Forward - Delete - Move to trash + Select all + Unselect all + Move to trash Delete permanently Message deleted successfully Share @@ -297,8 +298,8 @@ Read: %s Read by: %1$d of %2$d people - %d message - %d messages + %1$d message + %1$d messages New message @@ -310,6 +311,11 @@ You received %1$d message You received %1$d messages + + %1$d selected + %1$d selected + + Messages deleted