From 9e00e0fa9f5d7590ef8a50fd31b92faf663da078 Mon Sep 17 00:00:00 2001 From: andrekir Date: Mon, 28 Feb 2022 15:47:52 -0300 Subject: [PATCH] add action mode menu to messages (delete select all) --- .../geeksville/mesh/ui/MessagesFragment.kt | 200 ++++++++++++------ .../res/drawable/ic_twotone_delete_24.xml | 15 ++ .../res/drawable/ic_twotone_select_all_24.xml | 10 + .../res/layout/adapter_message_layout.xml | 1 - app/src/main/res/menu/menu_messages.xml | 14 ++ app/src/main/res/values-pt-rBR/strings.xml | 10 +- app/src/main/res/values-pt/strings.xml | 10 +- app/src/main/res/values/strings.xml | 7 +- app/src/main/res/values/styles.xml | 9 +- 9 files changed, 208 insertions(+), 68 deletions(-) create mode 100644 app/src/main/res/drawable/ic_twotone_delete_24.xml create mode 100644 app/src/main/res/drawable/ic_twotone_select_all_24.xml create mode 100644 app/src/main/res/menu/menu_messages.xml diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index a7c90157f..91cb9ff7f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -1,12 +1,10 @@ package com.geeksville.mesh.ui -import android.app.AlertDialog import android.graphics.Color +import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.text.InputType -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.view.* import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.ImageView @@ -14,11 +12,11 @@ import android.widget.TextView import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.geeksville.android.Logging import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MainActivity import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding @@ -26,6 +24,7 @@ import com.geeksville.mesh.databinding.MessagesFragmentBinding import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.MeshService import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.text.DateFormat import java.util.* @@ -37,7 +36,6 @@ fun EditText.on(actionId: Int, func: () -> Unit) { if (actionId == receivedActionId) { func() } - true } } @@ -45,6 +43,7 @@ fun EditText.on(actionId: Int, func: () -> Unit) { @AndroidEntryPoint class MessagesFragment : ScreenFragment("Messages"), Logging { + private var actionMode: ActionMode? = null private var _binding: MessagesFragmentBinding? = null // This property is only valid between onCreateView and onDestroyView. @@ -53,7 +52,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { private val model: UIViewModel by activityViewModels() // Allows textMultiline with IME_ACTION_SEND - fun EditText.onActionSend(func: () -> Unit) { + private fun EditText.onActionSend(func: () -> Unit) { setImeOptions(EditorInfo.IME_ACTION_SEND) setRawInputType(InputType.TYPE_CLASS_TEXT) setOnEditorActionListener { _, actionId, _ -> @@ -61,7 +60,6 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { if (actionId == EditorInfo.IME_ACTION_SEND) { func() } - true } } @@ -73,13 +71,12 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { private fun getShortDateTime(time: Date): String { // return time if within 24 hours, otherwise date/time - val one_day = 60 * 60 * 24 * 1000 - if (System.currentTimeMillis() - time.time > one_day) { - return dateTimeFormat.format(time) - } else return timeFormat.format(time) + val oneDayMsec = 60 * 60 * 24 * 1000L + return if (System.currentTimeMillis() - time.time > oneDayMsec) { + dateTimeFormat.format(time) + } else timeFormat.format(time) } - // Provide a direct reference to each of the views within a data item // Used to cache the views within the item layout for fast access class ViewHolder(itemView: AdapterMessageLayoutBinding) : @@ -119,8 +116,6 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val inflater = LayoutInflater.from(requireContext()) - // Inflate the custom layout - // Inflate the custom layout val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false) @@ -128,6 +123,9 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { return ViewHolder(contactViewBinding) } + var messages = arrayOf() + var positionList = ArrayList() + /** * Returns the total number of items in the data set held by the adapter. * @@ -167,30 +165,10 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { // Set cardview offset and color. val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset) - holder.card.setOnLongClickListener { - val deleteMessageDialog = AlertDialog.Builder(context) - deleteMessageDialog.setMessage(R.string.delete_selected_message) - deleteMessageDialog.setPositiveButton( - R.string.delete - ) { _, _ -> - model.messagesState.deleteMessage((messages[position]), position) - } - deleteMessageDialog.setNeutralButton( - R.string.cancel - ) { _, _ -> - } - deleteMessageDialog.setNegativeButton( - R.string.delete_all_messages - ) { _, _ -> - model.messagesState.deleteAllMessages() - } - deleteMessageDialog.create() - deleteMessageDialog.show() - true - } if (isMe) { marginParams.leftMargin = messageOffset marginParams.rightMargin = 0 + holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END context?.let { holder.card.setCardBackgroundColor( ContextCompat.getColor( @@ -202,6 +180,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { } else { marginParams.rightMargin = messageOffset marginParams.leftMargin = 0 + holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START context?.let { holder.card.setCardBackgroundColor( ContextCompat.getColor( @@ -243,9 +222,119 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { } else holder.messageStatusIcon.visibility = View.INVISIBLE + + holder.itemView.setOnLongClickListener { + if (actionMode == null) { + actionMode = (activity as MainActivity).startActionMode(object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.menu_messages, menu) + mode.title = "1" + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + clickItem(holder) + return true + } + + override fun onActionItemClicked( + mode: ActionMode, + item: MenuItem + ): Boolean { + when (item.itemId) { + R.id.deleteButton -> { + val deleteMessagesString = resources.getQuantityString( + R.plurals.delete_messages, + positionList.size, + positionList.size + ) + MaterialAlertDialogBuilder(requireContext()) + .setMessage(deleteMessagesString) + .setPositiveButton(getString(R.string.delete)) { _, _ -> + debug("User clicked deleteButton") + // all items selected --> deleteAllMessages() + if (positionList.size == messages.size) { + model.messagesState.deleteAllMessages() + } else { + // remove selectedList in reverse so we don't break the index + positionList.sortedDescending().forEach { + try { + model.messagesState.deleteMessage((messages[it]), it) + } catch (ex: ArrayIndexOutOfBoundsException) { + errormsg("error deleting messages ${ex.message}") + } + } + mode.finish() + } + } + .setNeutralButton(R.string.cancel) { _, _ -> + } + .show() + } + R.id.selectAllButton -> { + // if all selected -> unselect all + if (positionList.size == messages.size) { + positionList.clear() + mode.finish() + } else { + // else --> select all + positionList.clear() + positionList.addAll(messages.indices) + } + actionMode?.title = positionList.size.toString() + notifyDataSetChanged() + } + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + positionList.clear() + notifyDataSetChanged() + actionMode = null + } + }) + } else { + // when action mode is enabled + clickItem(holder) + } + true + } + holder.itemView.setOnClickListener { + if (actionMode != null) clickItem(holder) + } + + if (positionList.contains(position)) { + holder.itemView.background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 32f + setColor(Color.rgb(127, 127, 127)) + } + } else { + holder.itemView.background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 32f + setColor(ContextCompat.getColor(holder.itemView.context, R.color.colorAdvancedBackground)) + } + } } - private var messages = arrayOf() + private fun clickItem(holder: ViewHolder) { + val position = holder.bindingAdapterPosition + if (!positionList.contains(position)) { + positionList.add(position) + } else { + positionList.remove(position) + } + if (positionList.isEmpty()) { + // finish action mode when no items selected + actionMode?.finish() + } else { + // show total items selected on action mode title + actionMode?.title = "${positionList.size}" + } + notifyItemChanged(position) + } /// Called when our node DB changes fun onMessagesChanged(msgIn: Collection) { @@ -258,10 +347,15 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { } } + override fun onPause() { + actionMode?.finish() + super.onPause() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { _binding = MessagesFragmentBinding.inflate(inflater, container, false) return binding.root } @@ -269,7 +363,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.sendButton.setOnClickListener { - debug("sendButton click") + debug("User clicked sendButton") val str = binding.messageInputText.text.toString().trim() if (str.isNotEmpty()) @@ -295,34 +389,20 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { layoutManager.stackFromEnd = true // We want the last rows to always be shown binding.messageListView.layoutManager = layoutManager - model.messagesState.messages.observe(viewLifecycleOwner, Observer { + model.messagesState.messages.observe(viewLifecycleOwner) { debug("New messages received: ${it.size}") messagesAdapter.onMessagesChanged(it) - }) + } // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages - fun updateTextEnabled() { - binding.textInputLayout.isEnabled = - model.isConnected.value != MeshService.ConnectionState.DISCONNECTED + model.isConnected.observe(viewLifecycleOwner) { connectionState -> + // If we don't know our node ID and we are offline don't let user try to send + val connected = connectionState == MeshService.ConnectionState.CONNECTED + binding.textInputLayout.isEnabled = connected + binding.sendButton.isEnabled = connected // Just being connected is enough to allow sending texts I think // && model.nodeDB.myId.value != null && model.radioConfig.value != null } - - model.isConnected.observe(viewLifecycleOwner, Observer { _ -> - // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() - }) - - /* model.nodeDB.myId.observe(viewLifecycleOwner, Observer { _ -> - // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() - }) - - model.radioConfig.observe(viewLifecycleOwner, Observer { _ -> - // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() - }) */ } - } diff --git a/app/src/main/res/drawable/ic_twotone_delete_24.xml b/app/src/main/res/drawable/ic_twotone_delete_24.xml new file mode 100644 index 000000000..b77afdc91 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_delete_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_select_all_24.xml b/app/src/main/res/drawable/ic_twotone_select_all_24.xml new file mode 100644 index 000000000..c997121c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_select_all_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/adapter_message_layout.xml b/app/src/main/res/layout/adapter_message_layout.xml index 9d9f5db77..25f104d45 100644 --- a/app/src/main/res/layout/adapter_message_layout.xml +++ b/app/src/main/res/layout/adapter_message_layout.xml @@ -42,7 +42,6 @@ android:layout_marginEnd="8dp" android:autoLink="all" android:text="@string/sample_message" - android:textIsSelectable="true" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/username" app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/menu/menu_messages.xml b/app/src/main/res/menu/menu_messages.xml new file mode 100644 index 000000000..1182d80a6 --- /dev/null +++ b/app/src/main/res/menu/menu_messages.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 10fd89855..55c5c231a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,5 +1,5 @@ - + Configurações Nome do canal Opções do canal @@ -113,8 +113,14 @@ Permitir (exibe diálogo) Fornecer localização para mesh Permissão da câmera - Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados. + Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados. Curto alcance / lento Médio alcance / lento Longo alcance / lento + + Excluir mensagem? + Excluir %s mensagens? + + Excluir + Selecionar tudo diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4db69d416..962ba9b12 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,4 +1,4 @@ - + Configurações Nome do Canal Opções do Canal @@ -112,9 +112,15 @@ Cancelar (sem acesso ao rádio) Permitir (exibe diálogo) Fornecer localização para mesh - Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados. + Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados. Permissão da câmera Curto alcance / lento Médio alcance / lento Longo alcance / lento + + Excluir mensagem? + Excluir %s mensagens? + + Excluir + Selecionar tudo diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4345293e..89bb2a5dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,8 +120,11 @@ We must be granted access to the camera to read QR codes. No pictures or videos will be saved. Short Range / Slow Medium Range / Slow - Delete selected message? + + Delete message? + Delete %s messages? + Delete - Delete All Messages + Select all Long Range / Slow \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 664b688ac..54da37240 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -11,7 +11,8 @@ true @style/menu_item_color - + @style/MyActionMode + true + +