diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 23ac2c364..2f63a626b 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -141,6 +141,18 @@ class UIViewModel @Inject constructor( private val _focusedNode = MutableStateFlow(null) val focusedNode: StateFlow = _focusedNode + private val _nodeFilterText = MutableStateFlow("") + val nodeFilterText: StateFlow = _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 get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo @@ -615,4 +627,9 @@ class UIViewModel @Inject constructor( _currentTab.value = 1 _focusedNode.value = node } + + fun setNodeFilterText(text: String) { + _nodeFilterText.value = text + } + } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt index b8709c017..cdc6bfe21 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt @@ -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( diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index a6bcab437..588d46d84 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -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) { + 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) } + ) + } + } + } + } + } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt new file mode 100644 index 000000000..86dd40fbb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt @@ -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 = { } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt index 261ddb617..ba310667d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt @@ -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 diff --git a/app/src/main/res/layout/nodelist_fragment.xml b/app/src/main/res/layout/nodelist_fragment.xml index 3b2d3a883..4b4ef1c10 100644 --- a/app/src/main/res/layout/nodelist_fragment.xml +++ b/app/src/main/res/layout/nodelist_fragment.xml @@ -1,21 +1,34 @@ - + android:contentDescription="@string/a_list_of_nodes_in_the_mesh" + > + + + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/nodeFilter" + app:layout_constraintBottom_toBottomOf="parent" + /> + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7126abc29..8cf4bb4c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,8 @@ hey I found the cache, it is over here next to the big tiger. I\'m kinda scared. \??? + Filter + clear node filter ASL