package com.geeksville.mesh.ui import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.text.InputType import android.view.* import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.geeksville.android.Logging import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding 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.* // Allows usage like email.on(EditorInfo.IME_ACTION_NEXT, { confirm() }) fun EditText.on(actionId: Int, func: () -> Unit) { setOnEditorActionListener { _, receivedActionId, _ -> if (actionId == receivedActionId) { func() } true } } @AndroidEntryPoint class MessagesFragment : Fragment(), Logging { private val actionModeCallback: ActionModeCallback = ActionModeCallback() private var actionMode: ActionMode? = null private var _binding: MessagesFragmentBinding? = null // This property is only valid between onCreateView and onDestroyView. private val binding get() = _binding!! private var contactId: String = DataPacket.ID_BROADCAST private var contactName: String = DataPacket.ID_BROADCAST private val model: UIViewModel by activityViewModels() // Allows textMultiline with IME_ACTION_SEND private fun EditText.onActionSend(func: () -> Unit) { imeOptions = EditorInfo.IME_ACTION_SEND InputType.TYPE_TEXT_FLAG_MULTI_LINE setRawInputType(InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_SEND) { func() } true } } // 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) : RecyclerView.ViewHolder(itemView.root) { val username: Chip = itemView.username val messageText: TextView = itemView.messageText val messageTime: TextView = itemView.messageTime val messageStatusIcon: ImageView = itemView.messageStatusIcon val card: CardView = itemView.Card } private val messagesAdapter = object : RecyclerView.Adapter() { private val dateTimeFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) private val timeFormat: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT) private fun getShortDateTime(time: Date): String { // return time if within 24 hours, otherwise date/time val oneDayMsec = 60 * 60 * 24 * 1000L return if (System.currentTimeMillis() - time.time > oneDayMsec) { dateTimeFormat.format(time) } else timeFormat.format(time) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val inflater = LayoutInflater.from(requireContext()) // Inflate the custom layout val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false) // Return a new holder instance return ViewHolder(contactViewBinding) } var messages = arrayOf() var selectedList = ArrayList() override fun getItemCount(): Int = messages.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { val msg = messages[position] val nodes = model.nodeDB.nodes.value!! val node = nodes[msg.from] // Determine if this is my message (originated on this device) val isLocal = msg.from == DataPacket.ID_LOCAL // Set cardview offset and color. val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset) if (isLocal) { marginParams.leftMargin = messageOffset marginParams.rightMargin = 0 holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END context?.let { holder.card.setCardBackgroundColor( ContextCompat.getColor( it, R.color.colorMyMsg ) ) } } else { marginParams.rightMargin = messageOffset marginParams.leftMargin = 0 holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START context?.let { holder.card.setCardBackgroundColor( ContextCompat.getColor( it, R.color.colorMsg ) ) } } // Hide the username chip for my messages if (isLocal) { holder.username.visibility = View.GONE } else { holder.username.visibility = View.VISIBLE // If we can't find the sender, just use the ID val user = node?.user holder.username.text = user?.shortName ?: msg.from } if (msg.errorMessage != null) { context?.let { holder.card.setCardBackgroundColor(Color.RED) } holder.messageText.text = msg.errorMessage } else { holder.messageText.text = msg.text } holder.messageTime.text = getShortDateTime(Date(msg.time)) val icon = when (msg.status) { MessageStatus.QUEUED -> R.drawable.ic_twotone_cloud_upload_24 MessageStatus.DELIVERED -> R.drawable.cloud_on MessageStatus.ENROUTE -> R.drawable.ic_twotone_cloud_24 MessageStatus.ERROR -> R.drawable.cloud_off else -> null } if (icon != null) { holder.messageStatusIcon.setImageResource(icon) holder.messageStatusIcon.visibility = View.VISIBLE } else holder.messageStatusIcon.visibility = View.GONE holder.itemView.setOnLongClickListener { clickItem(holder) if (actionMode == null) { actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) } true } holder.itemView.setOnClickListener { if (actionMode != null) clickItem(holder) } if (selectedList.contains(msg)) { 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 fun clickItem(holder: ViewHolder) { val position = holder.bindingAdapterPosition if (!selectedList.contains(messages[position])) { selectedList.add(messages[position]) } else { selectedList.remove(messages[position]) } if (selectedList.isEmpty()) { // finish action mode when no items selected actionMode?.finish() } else { // show total items selected on action mode title actionMode?.title = selectedList.size.toString() } notifyItemChanged(position) } /// Called when our node DB changes fun onMessagesChanged(msgIn: Collection) { messages = msgIn.filter { if (contactId == DataPacket.ID_BROADCAST) it.to == DataPacket.ID_BROADCAST || it.delayed == 1 // MeshPacket.Delayed.DELAYED_BROADCAST_VALUE == 1 else it.from == contactId && it.to != DataPacket.ID_BROADCAST || it.from == DataPacket.ID_LOCAL && it.to == contactId }.toTypedArray() notifyDataSetChanged() // FIXME, this is super expensive and redraws all messages // scroll to the last line if (itemCount != 0) binding.messageListView.scrollToPosition(itemCount - 1) } } override fun onPause() { actionMode?.finish() super.onPause() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = MessagesFragmentBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setFragmentResultListener("requestKey") { _, bundle-> // get the result from bundle contactId = bundle.getString("contactId").toString() contactName = bundle.getString("contactName").toString() binding.messageTitle.text = contactName } binding.sendButton.setOnClickListener { debug("User clicked sendButton") val str = binding.messageInputText.text.toString().trim() if (str.isNotEmpty()) model.messagesState.sendMessage(str, contactId) binding.messageInputText.setText("") // blow away the string the user just entered // requireActivity().hideKeyboard() } binding.messageInputText.onActionSend { debug("did IME action") val str = binding.messageInputText.text.toString().trim() if (str.isNotEmpty()) model.messagesState.sendMessage(str) binding.messageInputText.setText("") // blow away the string the user just entered // requireActivity().hideKeyboard() } binding.messageListView.adapter = messagesAdapter val layoutManager = LinearLayoutManager(requireContext()) layoutManager.stackFromEnd = true // We want the last rows to always be shown binding.messageListView.layoutManager = layoutManager 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 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 } } private inner class ActionModeCallback : 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 { return false } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { R.id.deleteButton -> { val selectedList = messagesAdapter.selectedList val deleteMessagesString = resources.getQuantityString( R.plurals.delete_messages, selectedList.size, selectedList.size ) MaterialAlertDialogBuilder(requireContext()) .setMessage(deleteMessagesString) .setPositiveButton(getString(R.string.delete)) { _, _ -> debug("User clicked deleteButton") // all items selected --> deleteAllMessages() val messagesTotal = model.messagesState.messages.value if (messagesTotal != null && selectedList.size == messagesTotal.size) { model.messagesState.deleteAllMessages() } else { model.messagesState.deleteMessages(selectedList) } mode.finish() } .setNeutralButton(R.string.cancel) { _, _ -> } .show() } R.id.selectAllButton -> { // if all selected -> unselect all if (messagesAdapter.selectedList.size == messagesAdapter.messages.size) { messagesAdapter.selectedList.clear() mode.finish() } else { // else --> select all messagesAdapter.selectedList.clear() messagesAdapter.selectedList.addAll(messagesAdapter.messages) } actionMode?.title = messagesAdapter.selectedList.size.toString() messagesAdapter.notifyDataSetChanged() } R.id.resendButton -> { debug("User clicked resendButton") val selectedList = messagesAdapter.selectedList var resendText = "" selectedList.forEach { resendText = resendText + it.text + System.lineSeparator() } if (resendText!="") resendText = resendText.substring(0, resendText.length - 1) binding.messageInputText.setText(resendText) mode.finish() } } return true } override fun onDestroyActionMode(mode: ActionMode) { messagesAdapter.selectedList.clear() messagesAdapter.notifyDataSetChanged() actionMode = null } } }