sforkowany z mirror/meshtastic-android
428 wiersze
18 KiB
Kotlin
428 wiersze
18 KiB
Kotlin
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.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.MainActivity
|
|
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 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) {
|
|
setOnEditorActionListener { _, actionId, _ ->
|
|
|
|
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
|
func()
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 card: CardView = itemView.Card
|
|
val username: Chip = itemView.username
|
|
val messageText: TextView = itemView.messageText
|
|
val messageTime: TextView = itemView.messageTime
|
|
val messageStatusIcon: ImageView = itemView.messageStatusIcon
|
|
}
|
|
|
|
private val messagesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
|
|
|
/**
|
|
* Called when RecyclerView needs a new [ViewHolder] of the given type to represent
|
|
* an item.
|
|
*
|
|
*
|
|
* This new ViewHolder should be constructed with a new View that can represent the items
|
|
* of the given type. You can either create a new View manually or inflate it from an XML
|
|
* layout file.
|
|
*
|
|
*
|
|
* The new ViewHolder will be used to display items of the adapter using
|
|
* [.onBindViewHolder]. Since it will be re-used to display
|
|
* different items in the data set, it is a good idea to cache references to sub views of
|
|
* the View to avoid unnecessary [View.findViewById] calls.
|
|
*
|
|
* @param parent The ViewGroup into which the new View will be added after it is bound to
|
|
* an adapter position.
|
|
* @param viewType The view type of the new View.
|
|
*
|
|
* @return A new ViewHolder that holds a View of the given view type.
|
|
* @see .getItemViewType
|
|
* @see .onBindViewHolder
|
|
*/
|
|
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<DataPacket>()
|
|
var selectedList = ArrayList<DataPacket>()
|
|
|
|
/**
|
|
* Returns the total number of items in the data set held by the adapter.
|
|
*
|
|
* @return The total number of items in this adapter.
|
|
*/
|
|
override fun getItemCount(): Int = messages.size
|
|
|
|
/**
|
|
* Called by RecyclerView to display the data at the specified position. This method should
|
|
* update the contents of the [ViewHolder.itemView] to reflect the item at the given
|
|
* position.
|
|
*
|
|
*
|
|
* Note that unlike [android.widget.ListView], RecyclerView will not call this method
|
|
* again if the position of the item changes in the data set unless the item itself is
|
|
* invalidated or the new position cannot be determined. For this reason, you should only
|
|
* use the `position` parameter while acquiring the related data item inside
|
|
* this method and should not keep a copy of it. If you need the position of an item later
|
|
* on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
|
|
* have the updated adapter position.
|
|
*
|
|
* Override [.onBindViewHolder] instead if Adapter can
|
|
* handle efficient partial bind.
|
|
*
|
|
* @param holder The ViewHolder which should be updated to represent the contents of the
|
|
* item at the given position in the data set.
|
|
* @param position The position of the item within the adapter's data set.
|
|
*/
|
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
|
val msg = messages[position]
|
|
val nodes = model.nodeDB.nodes.value!!
|
|
|
|
// Determine if this is my message (originated on this device)
|
|
val isLocal = msg.from == DataPacket.ID_LOCAL
|
|
val isBroadcast = (msg.to == DataPacket.ID_BROADCAST
|
|
|| msg.delayed == 1) // MeshProtos.MeshPacket.Delayed.DELAYED_BROADCAST_VALUE == 1
|
|
|
|
// Filter messages by contactId
|
|
if (contactId == DataPacket.ID_BROADCAST) {
|
|
if (isBroadcast) {
|
|
holder.card.visibility = View.VISIBLE
|
|
} else holder.card.visibility = View.GONE
|
|
} else {
|
|
if (msg.from == contactId && msg.to != DataPacket.ID_BROADCAST
|
|
|| msg.from == DataPacket.ID_LOCAL && msg.to == contactId) {
|
|
holder.card.visibility = View.VISIBLE
|
|
} else holder.card.visibility = View.GONE
|
|
}
|
|
|
|
// Set cardview offset and color.
|
|
val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams
|
|
val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset)
|
|
if (isLocal) {
|
|
holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END
|
|
marginParams.leftMargin = messageOffset
|
|
marginParams.rightMargin = 0
|
|
context?.let {
|
|
holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMyMsg))
|
|
}
|
|
} else {
|
|
holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START
|
|
marginParams.rightMargin = messageOffset
|
|
marginParams.leftMargin = 0
|
|
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 node = nodes[msg.from]
|
|
val user = node?.user
|
|
holder.username.text = user?.shortName ?: msg.from
|
|
}
|
|
if (msg.errorMessage != null) {
|
|
holder.itemView.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.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,
|
|
selectedList.size,
|
|
selectedList.size
|
|
)
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setMessage(deleteMessagesString)
|
|
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
|
debug("User clicked deleteButton")
|
|
// all items selected --> deleteAllMessages()
|
|
if (selectedList.size == messages.size) {
|
|
model.messagesState.deleteAllMessages()
|
|
} else {
|
|
selectedList.forEach {
|
|
model.messagesState.deleteMessage(it)
|
|
}
|
|
}
|
|
mode.finish()
|
|
}
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
}
|
|
.show()
|
|
}
|
|
R.id.selectAllButton -> {
|
|
// filter messages by ContactId
|
|
val messagesByContactId = messages.filter {
|
|
if (contactId == DataPacket.ID_BROADCAST)
|
|
it.to == DataPacket.ID_BROADCAST
|
|
else
|
|
it.from == contactId && it.to != DataPacket.ID_BROADCAST
|
|
|| it.from == DataPacket.ID_LOCAL && it.to == contactId
|
|
}
|
|
// if all selected -> unselect all
|
|
if (selectedList.size == messagesByContactId.size) {
|
|
selectedList.clear()
|
|
mode.finish()
|
|
} else {
|
|
// else --> select all
|
|
selectedList.clear()
|
|
selectedList.addAll(messagesByContactId)
|
|
}
|
|
actionMode?.title = selectedList.size.toString()
|
|
notifyDataSetChanged()
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
override fun onDestroyActionMode(mode: ActionMode) {
|
|
selectedList.clear()
|
|
notifyDataSetChanged()
|
|
actionMode = null
|
|
}
|
|
})
|
|
} else {
|
|
// when action mode is enabled
|
|
clickItem(holder)
|
|
}
|
|
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<DataPacket>) {
|
|
messages = msgIn.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
|
|
}
|
|
}
|
|
}
|