mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
384 lines
14 KiB
Kotlin
384 lines
14 KiB
Kotlin
package com.geeksville.mesh.ui
|
|
|
|
import android.content.Context
|
|
import android.os.Bundle
|
|
import android.view.LayoutInflater
|
|
import android.view.MenuItem
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import androidx.appcompat.widget.PopupMenu
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.shadow
|
|
import androidx.compose.ui.platform.ComposeView
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.core.os.bundleOf
|
|
import androidx.fragment.app.activityViewModels
|
|
import androidx.fragment.app.setFragmentResult
|
|
import androidx.lifecycle.asLiveData
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
|
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.NodelistFragmentBinding
|
|
import com.geeksville.mesh.model.UIViewModel
|
|
import com.geeksville.mesh.ui.components.NodeFilterTextField
|
|
import com.geeksville.mesh.ui.theme.AppTheme
|
|
import com.geeksville.mesh.util.Exceptions
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
import kotlin.coroutines.resume
|
|
|
|
/**
|
|
* Workaround for RecyclerView bug throwing:
|
|
* java.lang.IndexOutOfBoundsException - Inconsistency detected. Invalid view holder adapter
|
|
*/
|
|
private class LinearLayoutManagerWrapper(context: Context) : LinearLayoutManager(context) {
|
|
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
|
|
try {
|
|
super.onLayoutChildren(recycler, state)
|
|
} catch (ex: IndexOutOfBoundsException) {
|
|
Exceptions.report(ex, "onLayoutChildren")
|
|
}
|
|
}
|
|
}
|
|
|
|
@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()
|
|
|
|
private val ignoreIncomingList: MutableList<Int> = mutableListOf()
|
|
private var gpsFormat = 0
|
|
private var displayUnits = 0
|
|
private var displayFahrenheit = false
|
|
|
|
class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
|
|
|
|
var shouldBlink by mutableStateOf(false)
|
|
|
|
suspend fun blink() {
|
|
shouldBlink = true
|
|
delay(500)
|
|
shouldBlink = false
|
|
}
|
|
|
|
fun bind(
|
|
thisNodeInfo: NodeInfo?,
|
|
thatNodeInfo: NodeInfo,
|
|
gpsFormat: Int,
|
|
distanceUnits: Int,
|
|
tempInFahrenheit: Boolean,
|
|
onChipClicked: () -> Unit
|
|
) {
|
|
composeView.setContent {
|
|
AppTheme {
|
|
NodeInfo(
|
|
thisNodeInfo = thisNodeInfo,
|
|
thatNodeInfo = thatNodeInfo,
|
|
gpsFormat = gpsFormat,
|
|
distanceUnits = distanceUnits,
|
|
tempInFahrenheit = tempInFahrenheit,
|
|
onClicked = onChipClicked,
|
|
blinking = shouldBlink,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val nodesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
|
|
|
var nodes = arrayOf<NodeInfo>()
|
|
private set
|
|
|
|
private fun popup(view: View, node: NodeInfo) {
|
|
if (!model.isConnected()) return
|
|
val user = node.user ?: return
|
|
val isOurNode = node.num == model.myNodeNum
|
|
val showAdmin = isOurNode || model.hasAdminChannel
|
|
val isIgnored = ignoreIncomingList.contains(node.num)
|
|
val popup = PopupMenu(requireContext(), view)
|
|
popup.inflate(R.menu.menu_nodes)
|
|
popup.menu.setGroupVisible(R.id.group_remote, !isOurNode)
|
|
popup.menu.setGroupVisible(R.id.group_admin, showAdmin)
|
|
popup.menu.setGroupEnabled(R.id.group_admin, !model.isManaged)
|
|
popup.menu.findItem(R.id.ignore).apply {
|
|
isEnabled = isIgnored || ignoreIncomingList.size < 3
|
|
isChecked = isIgnored
|
|
}
|
|
popup.setOnMenuItemClickListener { item: MenuItem ->
|
|
when (item.itemId) {
|
|
R.id.direct_message -> {
|
|
debug("calling MessagesFragment filter: ${node.channel}${user.id}")
|
|
model.setContactKey("${node.channel}${user.id}")
|
|
parentFragmentManager.beginTransaction()
|
|
.replace(R.id.mainActivityLayout, MessagesFragment())
|
|
.addToBackStack(null)
|
|
.commit()
|
|
}
|
|
R.id.request_position -> {
|
|
debug("requesting position for '${user.longName}'")
|
|
model.requestPosition(node.num)
|
|
}
|
|
R.id.traceroute -> {
|
|
debug("requesting traceroute for '${user.longName}'")
|
|
model.requestTraceroute(node.num)
|
|
}
|
|
R.id.forget_node -> {
|
|
debug("Forgetting node '${user.longName}'")
|
|
|
|
model.forgetNode(node.num)
|
|
onNodesChanged(nodes)
|
|
|
|
|
|
}
|
|
R.id.ignore -> {
|
|
val message = if (isIgnored) R.string.ignore_remove else R.string.ignore_add
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setTitle(R.string.ignore)
|
|
.setMessage(getString(message, user.longName))
|
|
.setNeutralButton(R.string.cancel) { _, _ -> }
|
|
.setPositiveButton(R.string.send) { _, _ ->
|
|
model.ignoreIncomingList = ignoreIncomingList.apply {
|
|
if (isIgnored) {
|
|
debug("removed '${user.longName}' from ignore list")
|
|
remove(node.num)
|
|
} else {
|
|
debug("added '${user.longName}' to ignore list")
|
|
add(node.num)
|
|
}
|
|
}
|
|
item.isChecked = !item.isChecked
|
|
notifyItemChanged(nodes.indexOfFirst { it.num == node.num })
|
|
}
|
|
.show()
|
|
}
|
|
R.id.remote_admin -> {
|
|
debug("calling remote admin --> destNum: ${node.num.toUInt()}")
|
|
setFragmentResult("requestKey", bundleOf("destNum" to node.num))
|
|
parentFragmentManager.beginTransaction()
|
|
.replace(R.id.mainActivityLayout, DeviceSettingsFragment())
|
|
.addToBackStack(null)
|
|
.commit()
|
|
}
|
|
}
|
|
true
|
|
}
|
|
popup.show()
|
|
}
|
|
|
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
|
return ViewHolder(ComposeView(parent.context))
|
|
}
|
|
|
|
override fun getItemCount(): Int = nodes.size
|
|
|
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
|
val thisNode = model.ourNodeInfo.value
|
|
val thatNode = nodes[position]
|
|
|
|
holder.bind(
|
|
thisNodeInfo = thisNode,
|
|
thatNodeInfo = thatNode,
|
|
gpsFormat = gpsFormat,
|
|
distanceUnits = displayUnits,
|
|
tempInFahrenheit = displayFahrenheit
|
|
) {
|
|
popup(holder.composeView, thatNode)
|
|
}
|
|
}
|
|
|
|
override fun onViewRecycled(holder: ViewHolder) {
|
|
holder.composeView.disposeComposition()
|
|
}
|
|
|
|
// Called when our node DB changes
|
|
fun onNodesChanged(nodesIn: Array<NodeInfo>) {
|
|
if (nodesIn.isEmpty()) {
|
|
notifyItemRangeRemoved(0, nodes.size)
|
|
nodes = emptyArray()
|
|
return
|
|
}
|
|
|
|
val previousNodes = nodes
|
|
|
|
if (nodesIn.size < previousNodes.size) {
|
|
notifyItemRangeRemoved(nodesIn.size, previousNodes.size - nodesIn.size)
|
|
}
|
|
|
|
val indexChanged = nodesIn.mapIndexed { index, nodeInfo ->
|
|
previousNodes.getOrNull(index) != nodeInfo
|
|
}
|
|
if (indexChanged.isEmpty()) return
|
|
|
|
nodes = nodesIn
|
|
for (i in indexChanged.indices) {
|
|
if (indexChanged[i]) {
|
|
notifyItemChanged(i)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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 = LinearLayoutManagerWrapper(requireContext())
|
|
|
|
binding.nodeFilter.initFilter()
|
|
|
|
model.filteredNodes.asLiveData().observe(viewLifecycleOwner) { nodeMap ->
|
|
nodesAdapter.onNodesChanged(nodeMap.values.toTypedArray())
|
|
}
|
|
|
|
model.localConfig.asLiveData().observe(viewLifecycleOwner) { config ->
|
|
ignoreIncomingList.apply {
|
|
clear()
|
|
addAll(config.lora.ignoreIncomingList)
|
|
}
|
|
gpsFormat = config.display.gpsFormat.number
|
|
displayUnits = config.display.units.number
|
|
}
|
|
|
|
model.moduleConfig.asLiveData().observe(viewLifecycleOwner) { module ->
|
|
displayFahrenheit = module.telemetry.environmentDisplayFahrenheit
|
|
}
|
|
|
|
model.tracerouteResponse.observe(viewLifecycleOwner) { response ->
|
|
MaterialAlertDialogBuilder(requireContext())
|
|
.setCancelable(false)
|
|
.setTitle(R.string.traceroute)
|
|
.setMessage(response ?: return@observe)
|
|
.setPositiveButton(R.string.okay) { _, _ -> }
|
|
.show()
|
|
|
|
model.clearTracerouteResponse()
|
|
}
|
|
|
|
model.focusedNode.asLiveData().observe(viewLifecycleOwner) { node ->
|
|
val idx = nodesAdapter.nodes.indexOfFirst {
|
|
it.user?.id == node?.user?.id
|
|
}
|
|
|
|
if (idx < 1) return@observe
|
|
|
|
lifecycleScope.launch {
|
|
with (binding.nodeListView.layoutManager as LinearLayoutManager) {
|
|
smoothScrollToTop(idx)
|
|
binding.nodeListView.awaitScrollStateIdle()
|
|
|
|
if (!isIndexAtTop(idx)) { // settle the scroll position
|
|
smoothScrollToTop(idx)
|
|
}
|
|
|
|
val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx)
|
|
(vh as? ViewHolder)?.blink() ?: warn("viewholder wasn't there. May need to wait for it")
|
|
model.focusUserNode(null)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onDestroyView() {
|
|
super.onDestroyView()
|
|
_binding = null
|
|
}
|
|
|
|
/**
|
|
* Scrolls the recycler view until the item at [position] is at the top of the view, then waits
|
|
* until the scrolling is finished.
|
|
* @param precision The time in milliseconds to wait between checks for the scroll state.
|
|
*/
|
|
private suspend fun RecyclerView.LayoutManager.smoothScrollToTop(
|
|
position: Int, precision: Long = 100
|
|
) {
|
|
this.startSmoothScroll(
|
|
object : LinearSmoothScroller(requireContext()) {
|
|
override fun getVerticalSnapPreference(): Int {
|
|
return SNAP_TO_START
|
|
}
|
|
}.apply {
|
|
targetPosition = position
|
|
}
|
|
)
|
|
|
|
while (isSmoothScrolling) {
|
|
delay(precision)
|
|
}
|
|
}
|
|
|
|
private fun ComposeView.initFilter() {
|
|
this.setContent {
|
|
val filterText by model.nodeFilterText.collectAsState()
|
|
AppTheme {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 8.dp)
|
|
.shadow(8.dp)
|
|
) {
|
|
NodeFilterTextField(
|
|
filterText = filterText,
|
|
onTextChanged = { model.setNodeFilterText(it) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun RecyclerView.awaitScrollStateIdle() = suspendCancellableCoroutine { continuation ->
|
|
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
warn("RecyclerView scrollState is already idle")
|
|
continuation.resume(Unit)
|
|
return@suspendCancellableCoroutine
|
|
}
|
|
|
|
val scrollListener = object : RecyclerView.OnScrollListener() {
|
|
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
recyclerView.removeOnScrollListener(this)
|
|
continuation.resume(Unit)
|
|
}
|
|
}
|
|
}
|
|
addOnScrollListener(scrollListener)
|
|
continuation.invokeOnCancellation {
|
|
removeOnScrollListener(scrollListener)
|
|
}
|
|
}
|
|
|
|
fun LinearLayoutManager.isIndexAtTop(idx: Int): Boolean {
|
|
val first = findFirstVisibleItemPosition()
|
|
val firstVisible = findFirstCompletelyVisibleItemPosition()
|
|
return first == idx && firstVisible == idx
|
|
}
|
|
|
|
}
|