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:
Davis 2024-03-31 13:39:35 -06:00 committed by GitHub
parent 5c6aadb5fd
commit 675c6a6b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 180 additions and 19 deletions

View file

@ -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
}
}

View file

@ -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(

View file

@ -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) }
)
}
}
}
}
}

View file

@ -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 = { }
)
}
}
}

View file

@ -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

View file

@ -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>

View file

@ -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>