mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
321 lines
14 KiB
Kotlin
321 lines
14 KiB
Kotlin
package com.geeksville.mesh.ui
|
|
|
|
import android.os.Bundle
|
|
import android.text.method.LinkMovementMethod
|
|
import android.view.LayoutInflater
|
|
import android.view.MenuItem
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import androidx.appcompat.widget.PopupMenu
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.os.bundleOf
|
|
import androidx.core.text.HtmlCompat
|
|
import androidx.fragment.app.activityViewModels
|
|
import androidx.fragment.app.setFragmentResult
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import com.geeksville.mesh.NodeInfo
|
|
import com.geeksville.mesh.R
|
|
import com.geeksville.mesh.android.Logging
|
|
import com.geeksville.mesh.databinding.AdapterNodeLayoutBinding
|
|
import com.geeksville.mesh.databinding.NodelistFragmentBinding
|
|
import com.geeksville.mesh.model.UIViewModel
|
|
import com.geeksville.mesh.util.formatAgo
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import java.net.URLEncoder
|
|
|
|
@AndroidEntryPoint
|
|
class UsersFragment : ScreenFragment("Users"), Logging {
|
|
|
|
private var _binding: NodelistFragmentBinding? = null
|
|
|
|
// This property is only valid between onCreateView and onDestroyView.
|
|
private val binding get() = _binding!!
|
|
|
|
private val model: UIViewModel by activityViewModels()
|
|
|
|
// 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: AdapterNodeLayoutBinding) : RecyclerView.ViewHolder(itemView.root) {
|
|
val chipNode = itemView.chipNode
|
|
val nodeNameView = itemView.nodeNameView
|
|
val distanceView = itemView.distanceView
|
|
val coordsView = itemView.coordsView
|
|
val batteryPctView = itemView.batteryPercentageView
|
|
val lastTime = itemView.lastConnectionView
|
|
val powerIcon = itemView.batteryIcon
|
|
val signalView = itemView.signalView
|
|
val envMetrics = itemView.envMetrics
|
|
}
|
|
|
|
private val nodesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
|
|
|
private var nodes = arrayOf<NodeInfo>()
|
|
|
|
private fun popup(view: View, position: Int) {
|
|
val node = nodes[position]
|
|
val user = node.user
|
|
val showAdmin = position == 0 || model.adminChannelIndex > 0
|
|
val popup = PopupMenu(requireContext(), view)
|
|
popup.inflate(R.menu.menu_nodes)
|
|
popup.menu.setGroupVisible(R.id.group_remote, position > 0)
|
|
popup.menu.setGroupVisible(R.id.group_admin, showAdmin)
|
|
popup.setOnMenuItemClickListener { item: MenuItem ->
|
|
when (item.itemId) {
|
|
R.id.direct_message -> {
|
|
if (position > 0 && user != null) {
|
|
debug("calling MessagesFragment filter: 0${user.id}")
|
|
setFragmentResult(
|
|
"requestKey",
|
|
bundleOf(
|
|
"contactKey" to "0${user.id}",
|
|
"contactName" to user.longName
|
|
)
|
|
)
|
|
parentFragmentManager.beginTransaction()
|
|
.replace(R.id.mainActivityLayout, MessagesFragment())
|
|
.addToBackStack(null)
|
|
.commit()
|
|
}
|
|
}
|
|
R.id.request_position -> {
|
|
if (position > 0 && user != null) {
|
|
debug("requesting position for ${user.longName}")
|
|
model.requestPosition(node.num)
|
|
}
|
|
}
|
|
R.id.reboot -> {
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("${getString(R.string.reboot)}\n${user?.longName}?")
|
|
.setIcon(R.drawable.ic_twotone_warning_24)
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
}
|
|
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
|
debug("User clicked requestReboot")
|
|
model.requestReboot(node.num)
|
|
}
|
|
.show()
|
|
}
|
|
R.id.shutdown -> {
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("${getString(R.string.shutdown)}\n${user?.longName}?")
|
|
.setIcon(R.drawable.ic_twotone_warning_24)
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
}
|
|
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
|
debug("User clicked requestShutdown")
|
|
model.requestShutdown(node.num)
|
|
}
|
|
.show()
|
|
}
|
|
R.id.factory_reset -> {
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("${getString(R.string.factory_reset)}\n${user?.longName}?")
|
|
.setIcon(R.drawable.ic_twotone_warning_24)
|
|
.setMessage(R.string.factory_reset_description)
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
}
|
|
.setPositiveButton(R.string.okay) { _, _ ->
|
|
debug("User clicked requestFactoryReset")
|
|
model.requestFactoryReset(node.num)
|
|
}
|
|
.show()
|
|
}
|
|
R.id.nodedb_reset -> {
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle("${getString(R.string.nodedb_reset)}\n${user?.longName}?")
|
|
.setIcon(R.drawable.ic_twotone_warning_24)
|
|
.setMessage(R.string.nodedb_reset_description)
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
}
|
|
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
|
debug("User clicked requestNodedbReset")
|
|
model.requestNodedbReset(node.num)
|
|
}
|
|
.show()
|
|
}
|
|
}
|
|
true
|
|
}
|
|
popup.show()
|
|
}
|
|
|
|
/**
|
|
* 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 contactView = AdapterNodeLayoutBinding.inflate(inflater, parent, false)
|
|
|
|
// Return a new holder instance
|
|
return ViewHolder(contactView)
|
|
}
|
|
|
|
/**
|
|
* 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 = nodes.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 n = nodes[position]
|
|
val user = n.user
|
|
holder.chipNode.text = user?.shortName ?: "UNK"
|
|
val name = user?.longName ?: "Unknown node"
|
|
holder.nodeNameView.text = name
|
|
|
|
val pos = n.validPosition
|
|
if (pos != null) {
|
|
val html = "<a href='geo:${pos.latitude},${pos.longitude}?z=17&label=${
|
|
URLEncoder.encode(name, "utf-8")
|
|
}'>${model.gpsString(pos)}</a>"
|
|
holder.coordsView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
|
holder.coordsView.movementMethod = LinkMovementMethod.getInstance()
|
|
holder.coordsView.visibility = View.VISIBLE
|
|
} else {
|
|
holder.coordsView.visibility = View.INVISIBLE
|
|
}
|
|
|
|
val ourNodeInfo = model.ourNodeInfo.value
|
|
val distance = ourNodeInfo?.distanceStr(n)
|
|
if (distance != null) {
|
|
holder.distanceView.text = distance
|
|
holder.distanceView.visibility = View.VISIBLE
|
|
} else {
|
|
holder.distanceView.visibility = View.INVISIBLE
|
|
}
|
|
renderBattery(n.batteryLevel, n.voltage, holder)
|
|
|
|
holder.lastTime.text = formatAgo(n.lastHeard)
|
|
|
|
if (n.envMetricStr.isNotEmpty()) {
|
|
holder.envMetrics.text = n.envMetricStr
|
|
holder.envMetrics.visibility = View.VISIBLE
|
|
} else {
|
|
holder.envMetrics.visibility = View.GONE
|
|
}
|
|
|
|
if (n.num == ourNodeInfo?.num) {
|
|
val info = model.myNodeInfo.value
|
|
if (info != null) {
|
|
val text =
|
|
String.format(
|
|
"ChUtil %.1f%% AirUtilTX %.1f%%",
|
|
n.deviceMetrics?.channelUtilization ?: info.channelUtilization,
|
|
n.deviceMetrics?.airUtilTx ?: info.airUtilTx
|
|
)
|
|
holder.signalView.text = text
|
|
holder.signalView.visibility = View.VISIBLE
|
|
}
|
|
} else {
|
|
if ((n.snr < 100f) && (n.rssi < 0)) {
|
|
val text = String.format("rssi:%d snr:%.1f", n.rssi, n.snr)
|
|
holder.signalView.text = text
|
|
holder.signalView.visibility = View.VISIBLE
|
|
} else {
|
|
holder.signalView.visibility = View.INVISIBLE
|
|
}
|
|
}
|
|
holder.chipNode.setOnClickListener {
|
|
popup(it, position)
|
|
}
|
|
holder.itemView.setOnLongClickListener {
|
|
popup(it, position)
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Called when our node DB changes
|
|
fun onNodesChanged(nodesIn: Array<NodeInfo>) {
|
|
if (nodesIn.size > 1)
|
|
nodesIn.sortWith(compareByDescending { it.lastHeard }, 1)
|
|
nodes = nodesIn
|
|
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
|
|
}
|
|
}
|
|
|
|
private fun renderBattery(
|
|
battery: Int?,
|
|
voltage: Float?,
|
|
holder: ViewHolder
|
|
) {
|
|
|
|
val (image, text) = when (battery) {
|
|
in 0..100 -> Pair(
|
|
R.drawable.ic_battery_full_24,
|
|
String.format("%d%% %.2fV", battery, voltage ?: 0)
|
|
)
|
|
101 -> Pair(R.drawable.ic_power_plug_24, "")
|
|
else -> Pair(R.drawable.ic_battery_full_24, "?")
|
|
}
|
|
|
|
holder.batteryPctView.text = text
|
|
holder.powerIcon.setImageDrawable(context?.let {
|
|
ContextCompat.getDrawable(it, image)
|
|
})
|
|
}
|
|
|
|
override fun onCreateView(
|
|
inflater: LayoutInflater, container: ViewGroup?,
|
|
savedInstanceState: Bundle?
|
|
): View {
|
|
_binding = NodelistFragmentBinding.inflate(inflater, container, false)
|
|
return binding.root
|
|
}
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
super.onViewCreated(view, savedInstanceState)
|
|
|
|
binding.nodeListView.adapter = nodesAdapter
|
|
binding.nodeListView.layoutManager = LinearLayoutManager(requireContext())
|
|
|
|
model.nodeDB.nodes.observe(viewLifecycleOwner) {
|
|
nodesAdapter.onNodesChanged(it.values.toTypedArray())
|
|
}
|
|
}
|
|
}
|