mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Feature: Filter node list (#920)
* Filter node list with text field against shortname and longname * Show filter hint * Reference "this" node from model instead of list position
This commit is contained in:
parent
5c6aadb5fd
commit
675c6a6b22
7 changed files with 180 additions and 19 deletions
|
|
@ -141,6 +141,18 @@ class UIViewModel @Inject constructor(
|
|||
private val _focusedNode = MutableStateFlow<NodeInfo?>(null)
|
||||
val focusedNode: StateFlow<NodeInfo?> = _focusedNode
|
||||
|
||||
private val _nodeFilterText = MutableStateFlow("")
|
||||
val nodeFilterText: StateFlow<String> = _nodeFilterText
|
||||
|
||||
val filteredNodes = nodeDB.nodeDBbyNum.combine(_nodeFilterText) { nodes, filterText ->
|
||||
if (filterText.isBlank()) return@combine nodes
|
||||
|
||||
nodes.filter { entry ->
|
||||
entry.value.user?.longName?.contains(filterText, ignoreCase = true) == true ||
|
||||
entry.value.user?.shortName?.contains(filterText, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
// hardware info about our local device (can be null)
|
||||
val myNodeInfo: StateFlow<MyNodeInfo?> get() = nodeDB.myNodeInfo
|
||||
val ourNodeInfo: StateFlow<NodeInfo?> get() = nodeDB.ourNodeInfo
|
||||
|
|
@ -615,4 +627,9 @@ class UIViewModel @Inject constructor(
|
|||
_currentTab.value = 1
|
||||
_focusedNode.value = node
|
||||
}
|
||||
|
||||
fun setNodeFilterText(text: String) {
|
||||
_nodeFilterText.value = text
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import com.geeksville.mesh.util.metersIn
|
|||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun NodeInfo(
|
||||
thisNodeInfo: NodeInfo,
|
||||
thisNodeInfo: NodeInfo?,
|
||||
thatNodeInfo: NodeInfo,
|
||||
gpsFormat: Int,
|
||||
distanceUnits: Int,
|
||||
|
|
@ -53,8 +53,8 @@ fun NodeInfo(
|
|||
val unknownLongName = stringResource(id = R.string.unknown_username)
|
||||
|
||||
val nodeName = thatNodeInfo.user?.longName ?: unknownLongName
|
||||
val isThisNode = thisNodeInfo.num == thatNodeInfo.num
|
||||
val distance = thisNodeInfo.distanceStr(thatNodeInfo, distanceUnits)
|
||||
val isThisNode = thisNodeInfo?.num == thatNodeInfo.num
|
||||
val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits)
|
||||
val (textColor, nodeColor) = thatNodeInfo.colors
|
||||
|
||||
Card(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.geeksville.mesh.ui
|
|||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
|
|
@ -11,7 +10,15 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.view.animation.LinearInterpolator
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.asLiveData
|
||||
|
|
@ -24,6 +31,7 @@ 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
|
||||
|
|
@ -67,8 +75,8 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
fun blink() {
|
||||
val bg = composeView.backgroundTintList
|
||||
ValueAnimator.ofArgb(
|
||||
Color.parseColor("#00FFFFFF"),
|
||||
Color.parseColor("#33FFFFFF")
|
||||
android.graphics.Color.parseColor("#00FFFFFF"),
|
||||
android.graphics.Color.parseColor("#33FFFFFF")
|
||||
).apply {
|
||||
interpolator = LinearInterpolator()
|
||||
startDelay = 500
|
||||
|
|
@ -86,7 +94,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
}
|
||||
|
||||
fun bind(
|
||||
thisNodeInfo: NodeInfo,
|
||||
thisNodeInfo: NodeInfo?,
|
||||
thatNodeInfo: NodeInfo,
|
||||
gpsFormat: Int,
|
||||
distanceUnits: Int,
|
||||
|
|
@ -188,7 +196,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
override fun getItemCount(): Int = nodes.size
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val thisNode = nodes[0]
|
||||
val thisNode = model.ourNodeInfo.value
|
||||
val thatNode = nodes[position]
|
||||
|
||||
holder.bind(
|
||||
|
|
@ -208,11 +216,22 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
|
||||
// Called when our node DB changes
|
||||
fun onNodesChanged(nodesIn: Array<NodeInfo>) {
|
||||
if (nodesIn.isEmpty()) {
|
||||
notifyItemRangeRemoved(0, nodes.size)
|
||||
nodes = emptyArray()
|
||||
return
|
||||
}
|
||||
|
||||
if (nodesIn.size > 1) {
|
||||
nodesIn.sortWith(compareByDescending { it.lastHeard }, 1)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -242,8 +261,10 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
binding.nodeListView.adapter = nodesAdapter
|
||||
binding.nodeListView.layoutManager = LinearLayoutManagerWrapper(requireContext())
|
||||
|
||||
model.nodeDB.nodeDBbyNum.asLiveData().observe(viewLifecycleOwner) {
|
||||
nodesAdapter.onNodesChanged(it.values.toTypedArray())
|
||||
binding.nodeFilter.initFilter()
|
||||
|
||||
model.filteredNodes.asLiveData().observe(viewLifecycleOwner) { nodeMap ->
|
||||
nodesAdapter.onNodesChanged(nodeMap.values.toTypedArray())
|
||||
}
|
||||
|
||||
model.localConfig.asLiveData().observe(viewLifecycleOwner) { config ->
|
||||
|
|
@ -311,4 +332,24 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun NodeFilterTextField(
|
||||
filterText : String = "",
|
||||
onTextChanged : (String) -> Unit
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 48.dp)
|
||||
.background(MaterialTheme.colors.background),
|
||||
value = filterText,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.node_filter_placeholder),
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground.copy(alpha = 0.35F)
|
||||
)
|
||||
},
|
||||
onValueChange = onTextChanged,
|
||||
trailingIcon = {
|
||||
if (filterText.isNotEmpty()) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = stringResource(id = R.string.desc_node_filter_clear),
|
||||
modifier = Modifier.clickable { onTextChanged("") }
|
||||
)
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = MaterialTheme.colors.onBackground
|
||||
),
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_NO)
|
||||
fun NodeFilterTextFieldPreview() {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
NodeFilterTextField(
|
||||
filterText = "Filter text",
|
||||
onTextChanged = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ private val DarkColorPalette = darkColors(
|
|||
primary = MeshtasticGreen,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Teal200,
|
||||
surface = AlmostBlack,
|
||||
onSurface = AlmostWhite
|
||||
)
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ private val LightColorPalette = lightColors(
|
|||
primary = MeshtasticGreen,
|
||||
primaryVariant = LightSkyBlue,
|
||||
secondary = Teal200,
|
||||
surface = AlmostWhite,
|
||||
onSurface = AlmostBlack
|
||||
|
||||
/* Other default colors to override
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/a_list_of_nodes_in_the_mesh">
|
||||
android:contentDescription="@string/a_list_of_nodes_in_the_mesh"
|
||||
>
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/nodeFilter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:elevation="8dp"
|
||||
tools:layout_height="48dp"
|
||||
tools:composableName="com.geeksville.mesh.ui.components.NodeFilterTextFieldKt.NodeFilterTextFieldPreview"
|
||||
/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/nodeListView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:contentDescription="@string/list_of_nodes"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/nodeFilter"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
<string name="sample_message" translatable="false">hey I found the cache, it is over here next to the big tiger. I\'m kinda scared.</string>
|
||||
|
||||
<string name="unknown_node_short_name" translatable="false">\???</string>
|
||||
<string name="node_filter_placeholder">Filter</string>
|
||||
<string name="desc_node_filter_clear">clear node filter</string>
|
||||
|
||||
<string name="elevation_suffix">ASL</string>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue