kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
feat: add nodelist sort options
rodzic
f84a75569d
commit
4ceb4c5199
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<List<NodeInfo>>
|
||||
|
||||
@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<Map<@MapColumn(columnName = "num") Int, NodeInfo>>
|
||||
|
||||
@Query("SELECT * FROM NodeInfo")
|
||||
fun nodeDBbyID(): Flow<Map<@MapColumn(columnName = "user_id") String, NodeInfo>>
|
||||
|
||||
@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<List<NodeInfo>>
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
|
@ -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<MyNodeInfo?>(null)
|
||||
val myNodeInfo: StateFlow<MyNodeInfo?> 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<MyNodeInfo?> = nodeInfoDao.getMyNodeInfo()
|
||||
fun nodeInfoFlow(): Flow<List<NodeInfo>> = nodeInfoDao.getNodes()
|
||||
|
||||
fun delNode(num: Int) {
|
||||
nodeInfoDao.delNode(num)
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
|
@ -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<NodeInfo?>(null)
|
||||
val focusedNode: StateFlow<NodeInfo?> = _focusedNode
|
||||
|
||||
private val _nodeFilterText = MutableStateFlow("")
|
||||
val nodeFilterText: StateFlow<String> = _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<NodesUiState> = 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<List<NodeInfo>> = 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<MyNodeInfo?> get() = nodeDB.myNodeInfo
|
||||
val ourNodeInfo: StateFlow<NodeInfo?> get() = nodeDB.ourNodeInfo
|
||||
|
@ -596,7 +633,7 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun setNodeFilterText(text: String) {
|
||||
_nodeFilterText.value = text
|
||||
nodeFilterText.value = text
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ class RadioConfigRepository @Inject constructor(
|
|||
/**
|
||||
* Flow representing the [NodeInfo] database.
|
||||
*/
|
||||
suspend fun getNodes(): List<NodeInfo>? = nodeDB.nodeInfoFlow().firstOrNull()
|
||||
suspend fun getNodes(): List<NodeInfo>? = nodeDB.getNodes().firstOrNull()
|
||||
|
||||
suspend fun upsert(node: NodeInfo) = nodeDB.upsert(node)
|
||||
suspend fun installNodeDB(mi: MyNodeInfo, nodes: List<NodeInfo>) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
|
@ -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,
|
||||
) {
|
|
@ -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,
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z" />
|
||||
</vector>
|
|
@ -16,6 +16,12 @@
|
|||
<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="node_filter_include_unknown">Include unknown</string>
|
||||
<string name="node_sort_alpha">A-Z</string>
|
||||
<string name="node_sort_channel">Channel</string>
|
||||
<string name="node_sort_distance">Distance</string>
|
||||
<string name="node_sort_last_heard">Last heard</string>
|
||||
<string name="node_sort_via_mqtt">via MQTT</string>
|
||||
|
||||
<string name="elevation_suffix">ASL</string>
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue