From 4ceb4c5199d23b5d6cc1aa05f10e6224715fde06 Mon Sep 17 00:00:00 2001 From: andrekir Date: Mon, 27 May 2024 09:56:26 -0300 Subject: [PATCH] feat: add nodelist sort options --- .../com/geeksville/mesh/NodeInfoDaoTest.kt | 115 ++++++++++++++---- .../mesh/database/dao/NodeInfoDao.kt | 62 +++++++--- .../java/com/geeksville/mesh/model/NodeDB.kt | 15 ++- .../com/geeksville/mesh/model/SortOption.kt | 12 ++ .../java/com/geeksville/mesh/model/UIState.kt | 57 +++++++-- .../datastore/RadioConfigRepository.kt | 2 +- .../java/com/geeksville/mesh/ui/NodeInfo.kt | 3 +- .../com/geeksville/mesh/ui/NodeSortButton.kt | 89 ++++++++++++++ .../com/geeksville/mesh/ui/UsersFragment.kt | 24 ++-- .../mesh/ui/components/NodeFilterTextField.kt | 29 ++++- .../ui/map/{components => }/CacheLayout.kt | 4 +- .../ui/map/{components => }/DownloadButton.kt | 4 +- .../{components => }/EditWaypointDialog.kt | 4 +- .../com/geeksville/mesh/ui/map/MapFragment.kt | 16 +-- .../{components => }/MapViewWithLifecycle.kt | 4 +- .../main/res/drawable/ic_twotone_sort_24.xml | 11 ++ app/src/main/res/values/strings.xml | 6 + 17 files changed, 368 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/SortOption.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/NodeSortButton.kt rename app/src/main/java/com/geeksville/mesh/ui/map/{components => }/CacheLayout.kt (98%) rename app/src/main/java/com/geeksville/mesh/ui/map/{components => }/DownloadButton.kt (96%) rename app/src/main/java/com/geeksville/mesh/ui/map/{components => }/EditWaypointDialog.kt (99%) rename app/src/main/java/com/geeksville/mesh/ui/map/{components => }/MapViewWithLifecycle.kt (95%) create mode 100644 app/src/main/res/drawable/ic_twotone_sort_24.xml diff --git a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt index 6b3d8c450..a5e87b4c8 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt @@ -5,10 +5,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.model.NodeSortOption import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -18,20 +21,20 @@ class NodeDBTest { private lateinit var database: MeshtasticDatabase private lateinit var nodeInfoDao: NodeInfoDao - private val testNodeNoPosition = NodeInfo( - 8, - MeshUser( + private val ourNodeInfo = NodeInfo( + num = 8, + user = MeshUser( "+16508765308".format(8), - "Kevin MesterNoLoc", + "Kevin Mester", "KLO", MeshProtos.HardwareModel.ANDROID_SIM, false ), - null + position = Position(30.267153, -97.743057, 35, 123), // Austin ) private val myNodeInfo: MyNodeInfo = MyNodeInfo( - myNodeNum = testNodeNoPosition.num, + myNodeNum = ourNodeInfo.num, hasGPS = false, model = null, firmwareVersion = null, @@ -47,22 +50,29 @@ class NodeDBTest { ) private val testPositions = arrayOf( - Position(32.776665, -96.796989, 35, 123), // dallas - Position(32.960758, -96.733521, 35, 456), // richardson - Position(32.912901, -96.781776, 35, 789), // north dallas + Position(32.776665, -96.796989, 35, 123), // Dallas + Position(32.960758, -96.733521, 35, 456), // Richardson + Position(32.912901, -96.781776, 35, 789), // North Dallas + Position(29.760427, -95.369804, 35, 123), // Houston + Position(33.748997, -84.387985, 35, 456), // Atlanta + Position(34.052235, -118.243683, 35, 789), // Los Angeles + Position(40.712776, -74.005974, 35, 123), // New York City + Position(41.878113, -87.629799, 35, 456), // Chicago + Position(39.952583, -75.165222, 35, 789), // Philadelphia ) - private val testNodes = listOf(testNodeNoPosition) + testPositions.mapIndexed { index, it -> + private val testNodes = listOf(ourNodeInfo) + testPositions.mapIndexed { index, it -> NodeInfo( - 9 + index, - MeshUser( + num = 9 + index, + user = MeshUser( "+165087653%02d".format(9 + index), "Kevin Mester$index", "KM$index", - MeshProtos.HardwareModel.ANDROID_SIM, + if (index == 2) MeshProtos.HardwareModel.UNSET else MeshProtos.HardwareModel.ANDROID_SIM, false ), - it + position = it, + lastHeard = 9 + index, ) } @@ -72,7 +82,7 @@ class NodeDBTest { database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build() nodeInfoDao = database.nodeInfoDao() - nodeInfoDao.apply{ + nodeInfoDao.apply { putAll(testNodes) setMyNodeInfo(myNodeInfo) } @@ -83,22 +93,83 @@ class NodeDBTest { database.close() } + /** + * Retrieves a list of nodes based on [sort], [filter] and [includeUnknown] parameters. + * The list excludes [ourNodeInfo] (our NodeInfo) to ensure consistency in the results. + */ + private suspend fun getNodes( + sort: NodeSortOption = NodeSortOption.LAST_HEARD, + filter: String = "", + includeUnknown: Boolean = true, + ) = nodeInfoDao.getNodes( + sort = sort.sqlValue, + filter = filter, + includeUnknown = includeUnknown, + unknownHwModel = MeshProtos.HardwareModel.UNSET + ).first().filter { it != ourNodeInfo } + @Test // node list size fun testNodeListSize() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(nodes.size, 4) + assertEquals(10, nodes.size) } @Test // nodeDBbyNum() re-orders our node at the top of the list - fun testOurNodeIntoIsFirst() = runBlocking { + fun testOurNodeInfoIsFirst() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(nodes.values.first(), testNodeNoPosition) + assertEquals(ourNodeInfo, nodes.values.first()) } - @Test // getNodeInfo() - fun testGetNodeInfo() = runBlocking { - for (node in nodeInfoDao.getNodes().first()) { - assertEquals(nodeInfoDao.getNodeInfo(node.num), node) + @Test + fun testSortByLastHeard() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.LAST_HEARD) + val sortedNodes = nodes.sortedByDescending { it.lastHeard } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByAlpha() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL) + val sortedNodes = nodes.sortedBy { it.user?.longName?.uppercase() } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByDistance() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.DISTANCE) + val sortedNodes = nodes.sortedBy { it.distance(ourNodeInfo) } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByChannel() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.CHANNEL) + val sortedNodes = nodes.sortedBy { it.channel } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByViaMqtt() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.VIA_MQTT) + val sortedNodes = nodes.sortedBy { it.user?.longName?.contains("(MQTT)") == true } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testIncludeUnknownIsFalse() = runBlocking { + val nodes = getNodes(includeUnknown = false) + val containsUnsetNode = nodes.any { node -> + node.user?.hwModel == MeshProtos.HardwareModel.UNSET } + assertFalse(containsUnsetNode) + } + + @Test + fun testIncludeUnknownIsTrue() = runBlocking { + val nodes = getNodes(includeUnknown = true) + val containsUnsetNode = nodes.any { node -> + node.user?.hwModel == MeshProtos.HardwareModel.UNSET + } + assertTrue(containsUnsetNode) } } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt index dc0f73993..1eeb8638c 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -6,6 +6,7 @@ import androidx.room.MapColumn import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Upsert +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo import kotlinx.coroutines.flow.Flow @@ -22,17 +23,54 @@ interface NodeInfoDao { @Query("DELETE FROM MyNodeInfo") fun clearMyNodeInfo() - @Query("SELECT * FROM NodeInfo") - fun getNodes(): Flow> - @Query("SELECT * FROM NodeInfo ORDER BY CASE WHEN num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) THEN 0 ELSE 1 END, lastHeard DESC") fun nodeDBbyNum(): Flow> @Query("SELECT * FROM NodeInfo") fun nodeDBbyID(): Flow> - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(node: NodeInfo) + @Query( + """ + WITH OurNode AS ( + SELECT position_latitude, position_longitude + FROM NodeInfo + WHERE num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) + ) + SELECT * FROM NodeInfo + WHERE (:includeUnknown = 1 OR user_hwModel != :unknownHwModel) + AND (:filter = '' + OR (user_longName LIKE '%' || :filter || '%' + OR user_shortName LIKE '%' || :filter || '%')) + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) THEN 0 + ELSE 1 + END, + CASE + WHEN :sort = 'last_heard' THEN lastHeard * -1 + WHEN :sort = 'alpha' THEN UPPER(user_longName) + WHEN :sort = 'distance' THEN + CASE + WHEN position_latitude IS NULL OR position_longitude IS NULL OR + (position_latitude = 0 AND position_longitude = 0) THEN 999999999 + ELSE + (position_latitude - (SELECT position_latitude FROM OurNode)) * + (position_latitude - (SELECT position_latitude FROM OurNode)) + + (position_longitude - (SELECT position_longitude FROM OurNode)) * + (position_longitude - (SELECT position_longitude FROM OurNode)) + END + WHEN :sort = 'channel' THEN channel + WHEN :sort = 'via_mqtt' THEN user_longName LIKE '%(MQTT)' -- viaMqtt + ELSE 0 + END ASC, + lastHeard DESC + """ + ) + fun getNodes( + sort: String, + filter: String, + includeUnknown: Boolean, + unknownHwModel: MeshProtos.HardwareModel + ): Flow> @Upsert fun upsert(node: NodeInfo) @@ -45,18 +83,4 @@ interface NodeInfoDao { @Query("DELETE FROM NodeInfo WHERE num=:num") fun delNode(num: Int) - - @Query("SELECT * FROM NodeInfo WHERE num=:num") - fun getNodeInfo(num: Int): NodeInfo? - -// @Transaction -// suspend fun updateUser(num: Int, updatedUser: MeshUser) { -// getNodeInfo(num)?.let { -// val updatedNodeInfo = it.copy(user = updatedUser) -// upsert(updatedNodeInfo) -// } -// } - -// @Query("Update node_info set position=:position WHERE num=:num") -// fun updatePosition(num: Int, position: MeshProtos.Position) } diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt index b51490aa9..faf5596b0 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh.model import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.database.dao.NodeInfoDao @@ -20,7 +21,6 @@ class NodeDB @Inject constructor( processLifecycle: Lifecycle, private val nodeInfoDao: NodeInfoDao, ) { - // hardware info about our local device (can be null) private val _myNodeInfo = MutableStateFlow(null) val myNodeInfo: StateFlow get() = _myNodeInfo @@ -58,8 +58,19 @@ class NodeDB @Inject constructor( .launchIn(processLifecycle.coroutineScope) } + fun getNodes( + sort: NodeSortOption = NodeSortOption.LAST_HEARD, + filter: String = "", + includeUnknown: Boolean = true, + ) = nodeInfoDao.getNodes( + sort = sort.sqlValue, + filter = filter, + includeUnknown = includeUnknown, + unknownHwModel = MeshProtos.HardwareModel.UNSET + ) + fun myNodeInfoFlow(): Flow = nodeInfoDao.getMyNodeInfo() - fun nodeInfoFlow(): Flow> = nodeInfoDao.getNodes() + fun delNode(num: Int) { nodeInfoDao.delNode(num) } diff --git a/app/src/main/java/com/geeksville/mesh/model/SortOption.kt b/app/src/main/java/com/geeksville/mesh/model/SortOption.kt new file mode 100644 index 000000000..5924f5c0d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/SortOption.kt @@ -0,0 +1,12 @@ +package com.geeksville.mesh.model + +import androidx.annotation.StringRes +import com.geeksville.mesh.R + +enum class NodeSortOption(val sqlValue: String, @StringRes val stringRes: Int) { + LAST_HEARD("last_heard", R.string.node_sort_last_heard), + ALPHABETICAL("alpha", R.string.node_sort_alpha), + DISTANCE("distance", R.string.node_sort_distance), + CHANNEL("channel", R.string.node_sort_channel), + VIA_MQTT("via_mqtt", R.string.node_sort_via_mqtt), +} 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 52df248c2..bfb1e2a7d 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedWriter @@ -97,6 +99,16 @@ internal fun getChannelList( } } +data class NodesUiState( + val sort: NodeSortOption = NodeSortOption.LAST_HEARD, + val filter: String = "", + val includeUnknown: Boolean = false, +) { + companion object { + val Empty = NodesUiState() + } +} + @HiltViewModel class UIViewModel @Inject constructor( private val app: Application, @@ -138,18 +150,43 @@ class UIViewModel @Inject constructor( private val _focusedNode = MutableStateFlow(null) val focusedNode: StateFlow = _focusedNode - private val _nodeFilterText = MutableStateFlow("") - val nodeFilterText: StateFlow = _nodeFilterText + private val nodeFilterText = MutableStateFlow("") + private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD) + private val includeUnknown = MutableStateFlow(false) - 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 - } + fun setSortOption(sort: NodeSortOption) { + nodeSortOption.value = sort } + fun toggleIncludeUnknown() { + includeUnknown.value = !includeUnknown.value + } + + val nodeViewState: StateFlow = combine( + nodeFilterText, + nodeSortOption, + includeUnknown, + ) { filter, sort, includeUnknown -> + NodesUiState( + sort = sort, + filter = filter, + includeUnknown = includeUnknown, + ) + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(), + initialValue = NodesUiState.Empty, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val filteredNodes: StateFlow> = nodeViewState.flatMapLatest { state -> + nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = emptyList(), + ) + // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo @@ -596,7 +633,7 @@ class UIViewModel @Inject constructor( } fun setNodeFilterText(text: String) { - _nodeFilterText.value = text + nodeFilterText.value = text } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index 1fe986b70..96eee69f4 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -84,7 +84,7 @@ class RadioConfigRepository @Inject constructor( /** * Flow representing the [NodeInfo] database. */ - suspend fun getNodes(): List? = nodeDB.nodeInfoFlow().firstOrNull() + suspend fun getNodes(): List? = nodeDB.getNodes().firstOrNull() suspend fun upsert(node: NodeInfo) = nodeDB.upsert(node) suspend fun installNodeDB(mi: MyNodeInfo, nodes: List) { 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 2ad2b742c..88a02b7d3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.ui.compose.ElevationInfo @@ -137,7 +138,7 @@ fun NodeInfo( ) } - val style = if (nodeName == unknownLongName) { + val style = if (thatNodeInfo.user?.hwModel == MeshProtos.HardwareModel.UNSET) { LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) } else { LocalTextStyle.current diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeSortButton.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeSortButton.kt new file mode 100644 index 000000000..93ba95060 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeSortButton.kt @@ -0,0 +1,89 @@ +package com.geeksville.mesh.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.NodeSortOption + +@Composable +internal fun NodeSortButton( + currentSortOption: NodeSortOption, + onSortSelected: (NodeSortOption) -> Unit, + includeUnknown: Boolean, + onToggleIncludeUnknown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + var expanded by remember { mutableStateOf(false) } + + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_sort_24), + contentDescription = null, + modifier = Modifier.heightIn(max = 48.dp), + tint = MaterialTheme.colors.onSurface + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colors.background.copy(alpha = 1f)) + ) { + NodeSortOption.entries.forEach { sort -> + DropdownMenuItem( + onClick = { + onSortSelected(sort) + expanded = false + }, + ) { + Text( + text = stringResource(id = sort.stringRes), + fontWeight = if (sort == currentSortOption) FontWeight.Bold else null, + ) + } + } + Divider() + DropdownMenuItem( + onClick = { + onToggleIncludeUnknown() + expanded = false + }, + ) { + Text( + text = stringResource(id = R.string.node_filter_include_unknown), + ) + AnimatedVisibility(visible = includeUnknown) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + } +} 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 8933d74a5..453161b96 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -7,21 +7,20 @@ 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.Row 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.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller @@ -262,7 +261,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { binding.nodeFilter.initFilter() model.filteredNodes.asLiveData().observe(viewLifecycleOwner) { nodeMap -> - nodesAdapter.onNodesChanged(nodeMap.values.toTypedArray()) + nodesAdapter.onNodesChanged(nodeMap.toTypedArray()) } model.localConfig.asLiveData().observe(viewLifecycleOwner) { config -> @@ -343,17 +342,24 @@ class UsersFragment : ScreenFragment("Users"), Logging { private fun ComposeView.initFilter() { this.setContent { - val filterText by model.nodeFilterText.collectAsState() + val nodeViewState by model.nodeViewState.collectAsStateWithLifecycle() + AppTheme { - Box( + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp) - .shadow(8.dp) ) { NodeFilterTextField( - filterText = filterText, - onTextChanged = { model.setNodeFilterText(it) } + filterText = nodeViewState.filter, + onTextChanged = model::setNodeFilterText, + modifier = Modifier.weight(1f) + ) + NodeSortButton( + currentSortOption = nodeViewState.sort, + onSortSelected = model::setSortOption, + includeUnknown = nodeViewState.includeUnknown, + onToggleIncludeUnknown = model::toggleIncludeUnknown, ) } } 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 index 86dd40fbb..21b51590a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt @@ -13,8 +13,14 @@ 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.material.icons.filled.Search import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -26,15 +32,17 @@ import com.geeksville.mesh.ui.theme.AppTheme @Composable fun NodeFilterTextField( - filterText : String = "", - onTextChanged : (String) -> Unit + filterText : String, + onTextChanged : (String) -> Unit, + modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current + var isFocused by remember { mutableStateOf(false) } OutlinedTextField( - modifier = Modifier - .fillMaxWidth() + modifier = modifier .heightIn(max = 48.dp) + .onFocusEvent { isFocused = it.isFocused } .background(MaterialTheme.colors.background), value = filterText, placeholder = { @@ -44,13 +52,22 @@ fun NodeFilterTextField( color = MaterialTheme.colors.onBackground.copy(alpha = 0.35F) ) }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = stringResource(id = R.string.node_filter_placeholder), + ) + }, onValueChange = onTextChanged, trailingIcon = { - if (filterText.isNotEmpty()) { + if (filterText.isNotEmpty() || isFocused) { Icon( Icons.Default.Clear, contentDescription = stringResource(id = R.string.desc_node_filter_clear), - modifier = Modifier.clickable { onTextChanged("") } + modifier = Modifier.clickable { + onTextChanged("") + focusManager.clearFocus() + } ) } }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt b/app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt similarity index 98% rename from app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt index 1ebffafdc..b292543f4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.ui.map.components +package com.geeksville.mesh.ui.map import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp import com.geeksville.mesh.R @Composable -fun CacheLayout( +internal fun CacheLayout( cacheEstimate: String, onExecuteJob: () -> Unit, onCancelDownload: () -> Unit, diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt b/app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt similarity index 96% rename from app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt index 3431823c7..c6bf124a7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.ui.map.components +package com.geeksville.mesh.ui.map import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing @@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource import com.geeksville.mesh.R @Composable -fun DownloadButton( +internal fun DownloadButton( enabled: Boolean, onClick: () -> Unit, ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt similarity index 99% rename from app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt index 152ae973c..71508d10d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.ui.map.components +package com.geeksville.mesh.ui.map import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -42,7 +42,7 @@ import com.geeksville.mesh.util.CustomRecentEmojiProvider import com.geeksville.mesh.waypoint @Composable -fun EditWaypointDialog( +internal fun EditWaypointDialog( waypoint: Waypoint, onSendClicked: (Waypoint) -> Unit, onDeleteClicked: (Waypoint) -> Unit, diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index e6fd0ba85..e890756eb 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -50,19 +50,14 @@ import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.map.CustomTileSource import com.geeksville.mesh.model.map.MarkerWithLabel -import com.geeksville.mesh.ui.MessagesFragment import com.geeksville.mesh.ui.ScreenFragment -import com.geeksville.mesh.ui.map.components.CacheLayout -import com.geeksville.mesh.ui.map.components.DownloadButton -import com.geeksville.mesh.ui.map.components.EditWaypointDialog import com.geeksville.mesh.ui.components.IconButton -import com.geeksville.mesh.ui.map.components.rememberMapViewWithLifecycle +import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.SqlTileWriterExt import com.geeksville.mesh.util.requiredZoomLevel import com.geeksville.mesh.util.formatAgo import com.geeksville.mesh.util.zoomIn import com.geeksville.mesh.waypoint -import com.google.accompanist.themeadapter.appcompat.AppCompatTheme import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable @@ -90,7 +85,6 @@ import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import java.io.File import java.text.DateFormat - @AndroidEntryPoint class MapFragment : ScreenFragment("Map Fragment"), Logging { @@ -104,7 +98,7 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - AppCompatTheme { + AppTheme { MapView(model) } } @@ -192,7 +186,7 @@ fun MapView( requestPermissionAndToggleLauncher.launch(context.getLocationPermissions()) } - val nodes by model.nodeDB.nodes.collectAsStateWithLifecycle() + val nodes by model.filteredNodes.collectAsStateWithLifecycle(emptyList()) val waypoints by model.waypoints.observeAsState(emptyMap()) var showDownloadButton: Boolean by remember { mutableStateOf(false) } @@ -462,7 +456,7 @@ fun MapView( } with(map) { - UpdateMarkers(onNodesChanged(nodes.values), onWaypointChanged(waypoints.values)) + UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values)) } // private fun addWeatherLayer() { @@ -482,7 +476,7 @@ fun MapView( // } fun MapView.zoomToNodes() { - val nodeMarkers = onNodesChanged(nodes.values) + val nodeMarkers = onNodesChanged(nodes) if (nodeMarkers.isNotEmpty()) { val box = BoundingBox.fromGeoPoints(nodeMarkers.map { it.position }) val center = GeoPoint(box.centerLatitude, box.centerLongitude) diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/MapViewWithLifecycle.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt similarity index 95% rename from app/src/main/java/com/geeksville/mesh/ui/map/components/MapViewWithLifecycle.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt index bd1a37eea..457867e89 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/components/MapViewWithLifecycle.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.ui.map.components +package com.geeksville.mesh.ui.map import android.annotation.SuppressLint import android.content.Context @@ -32,7 +32,7 @@ private fun PowerManager.WakeLock.safeRelease() { } @Composable -fun rememberMapViewWithLifecycle(context: Context): MapView { +internal fun rememberMapViewWithLifecycle(context: Context): MapView { val mapView = remember { MapView(context).apply { clipToOutline = true diff --git a/app/src/main/res/drawable/ic_twotone_sort_24.xml b/app/src/main/res/drawable/ic_twotone_sort_24.xml new file mode 100644 index 000000000..a58bd6881 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_sort_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da03bc29e..c5bea1428 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,12 @@ \??? Filter clear node filter + Include unknown + A-Z + Channel + Distance + Last heard + via MQTT ASL