diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt index 4a1bc94f9..728c75720 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -28,6 +28,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.NodeSortOption +import com.geeksville.mesh.model.isUnmessageableRole import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -96,11 +97,22 @@ class NodeRepository @Inject constructor( sort: NodeSortOption = NodeSortOption.LAST_HEARD, filter: String = "", includeUnknown: Boolean = true, + includeUnmessageable: Boolean = true, ) = nodeInfoDao.getNodes( sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, - ).mapLatest { list -> list.map { it.toModel() } }.flowOn(dispatchers.io).conflate() + ).mapLatest { list -> + list.map { it.toModel() }.filter { node -> + val isUnmessageable: Boolean = if (node.user.hasIsUnmessagable()) { + node.user.isUnmessagable + } else { + // for older firmwares + node.user.role?.isUnmessageableRole() == true + } + return@filter includeUnmessageable || !isUnmessageable + } + }.flowOn(dispatchers.io).conflate() suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) diff --git a/app/src/main/java/com/geeksville/mesh/model/Node.kt b/app/src/main/java/com/geeksville/mesh/model/Node.kt index ccd155385..7c979785c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Node.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Node.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.model import android.graphics.Color +import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.PaxcountProtos @@ -143,3 +144,13 @@ data class Node( ).joinToString(" ") } } + +fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf( + ConfigProtos.Config.DeviceConfig.Role.REPEATER, + ConfigProtos.Config.DeviceConfig.Role.ROUTER, + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, + ConfigProtos.Config.DeviceConfig.Role.SENSOR, + ConfigProtos.Config.DeviceConfig.Role.TRACKER, + ConfigProtos.Config.DeviceConfig.Role.TAK, + ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER, +) 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 18f8eb732..383ec01d1 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -147,6 +147,7 @@ data class NodesUiState( val sort: NodeSortOption = NodeSortOption.LAST_HEARD, val filter: String = "", val includeUnknown: Boolean = false, + val includeUnmessageable: Boolean = true, val gpsFormat: Int = 0, val distanceUnits: Int = 0, val tempInFahrenheit: Boolean = false, @@ -166,6 +167,7 @@ data class Contact( val unreadCount: Int, val messageCount: Int, val isMuted: Boolean, + val isUnmessageable: Boolean, ) @Suppress("LongParameterList") @@ -261,6 +263,8 @@ class UIViewModel @Inject constructor( private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD) private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false)) private val showDetails = MutableStateFlow(preferences.getBoolean("show-details", false)) + private val includeUnmessageable = + MutableStateFlow(preferences.getBoolean("include-unmessageable", true)) fun setSortOption(sort: NodeSortOption) { nodeSortOption.value = sort @@ -271,6 +275,11 @@ class UIViewModel @Inject constructor( preferences.edit { putBoolean("show-details", showDetails.value) } } + fun toggleIncludeUnmessageable() { + includeUnmessageable.value = !includeUnmessageable.value + preferences.edit { putBoolean("include-unmessageable", includeUnmessageable.value) } + } + fun toggleIncludeUnknown() { includeUnknown.value = !includeUnknown.value preferences.edit { putBoolean("include-unknown", includeUnknown.value) } @@ -292,6 +301,8 @@ class UIViewModel @Inject constructor( tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, showDetails = showDetails, ) + }.combine(includeUnmessageable) { state, includeUnmessageable -> + state.copy(includeUnmessageable = includeUnmessageable) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -299,7 +310,7 @@ class UIViewModel @Inject constructor( ) val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> - nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) + nodeDB.getNodes(state.sort, state.filter, state.includeUnknown, state.includeUnmessageable) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -395,6 +406,7 @@ class UIViewModel @Inject constructor( unreadCount = packetRepository.getUnreadCount(contactKey), messageCount = packetRepository.getMessageCount(contactKey), isMuted = settings[contactKey]?.isMuted == true, + isUnmessageable = user.isUnmessagable, ) } }.stateIn( diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt index 94ed086e6..dfad3725d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt @@ -162,6 +162,7 @@ private fun ContactItemPreview() { unreadCount = 2, messageCount = 10, isMuted = true, + isUnmessageable = false, ), selected = false, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index 8f2afcd2f..87653925e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -61,6 +61,7 @@ import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.WaterDrop import androidx.compose.material.icons.filled.Work import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.NoCell import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -95,6 +96,7 @@ import com.geeksville.mesh.model.MetricsState import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider @@ -341,6 +343,7 @@ fun DeviceHardwareImage( } } +@Suppress("LongMethod") @Composable private fun NodeDetailsContent( node: Node, @@ -382,6 +385,18 @@ private fun NodeDetailsContent( icon = Icons.Default.Work, value = node.user.role.name ) + val unmessageable = if (node.user.hasIsUnmessagable()) { + node.user.isUnmessagable + } else { + node.user.role?.isUnmessageableRole() == true + } + if (unmessageable) { + NodeDetailRow( + label = stringResource(R.string.unmonitored_or_infrastructure), + icon = Icons.Outlined.NoCell, + value = "" + ) + } if (node.deviceMetrics.uptimeSeconds > 0) { NodeDetailRow( label = stringResource(R.string.uptime), diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index c056c8290..b470ede91 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -30,10 +30,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NoCell import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -58,6 +61,7 @@ import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R import com.geeksville.mesh.model.Node +import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.ui.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.components.NodeMenu import com.geeksville.mesh.ui.components.NodeMenuAction @@ -110,7 +114,11 @@ fun NodeItem( } val (detailsShown, showDetails) = remember { mutableStateOf(expanded) } - + val unmessageable = if (thatNode.user.hasIsUnmessagable()) { + thatNode.user.isUnmessagable + } else { + thatNode.user.role?.isUnmessageableRole() == true + } Card( modifier = modifier .fillMaxWidth() @@ -183,6 +191,21 @@ fun NodeItem( currentTimeMillis = currentTimeMillis ) } + if (unmessageable) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.NoCell, + contentDescription = stringResource(R.string.unmessageable), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) + Text( + stringResource(R.string.unmonitored_or_infrastructure) + ) + } + } Row( modifier = Modifier .fillMaxWidth(), diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt index 90699a5f2..786b1af9b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt @@ -95,6 +95,8 @@ fun NodeScreen( onToggleIncludeUnknown = model::toggleIncludeUnknown, showDetails = state.showDetails, onToggleShowDetails = model::toggleShowDetails, + includeUnmessageable = state.includeUnmessageable, + onToggleIncludeUnmessageable = model::toggleIncludeUnmessageable ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Share.kt b/app/src/main/java/com/geeksville/mesh/ui/Share.kt index 37f83ba98..2bd1ba065 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Share.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Share.kt @@ -112,6 +112,7 @@ private fun ShareScreenPreview() { unreadCount = 2, messageCount = 10, isMuted = true, + isUnmessageable = false, ), ), onConfirm = {}, 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 e10ce5585..0974b604c 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 @@ -70,6 +70,8 @@ fun NodeFilterTextField( onToggleIncludeUnknown: () -> Unit, showDetails: Boolean, onToggleShowDetails: () -> Unit, + includeUnmessageable: Boolean, + onToggleIncludeUnmessageable: () -> Unit, ) { Row( modifier = modifier.background(MaterialTheme.colorScheme.background), @@ -88,6 +90,8 @@ fun NodeFilterTextField( onToggleIncludeUnknown = onToggleIncludeUnknown, showDetails = showDetails, onToggleShowDetails = onToggleShowDetails, + includeUnmessageable = includeUnmessageable, + onToggleIncludeUnmessageable = onToggleIncludeUnmessageable ) } } @@ -154,6 +158,8 @@ private fun NodeSortButton( onToggleIncludeUnknown: () -> Unit, showDetails: Boolean, onToggleShowDetails: () -> Unit, + onToggleIncludeUnmessageable: () -> Unit, + includeUnmessageable: Boolean, modifier: Modifier = Modifier, ) = Box(modifier) { var expanded by remember { mutableStateOf(false) } @@ -228,6 +234,27 @@ private fun NodeSortButton( } } ) + HorizontalDivider() + DropdownMenuItem( + onClick = { + onToggleIncludeUnmessageable() + expanded = false + }, + text = { + Row { + AnimatedVisibility(visible = includeUnmessageable) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + } + Text( + text = stringResource(id = R.string.node_filter_include_unmessageable), + ) + } + } + ) } } @@ -245,6 +272,8 @@ private fun NodeFilterTextFieldPreview() { onToggleIncludeUnknown = {}, showDetails = false, onToggleShowDetails = {}, + includeUnmessageable = false, + onToggleIncludeUnmessageable = {} ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt index 11191112a..35a26f30c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import com.geeksville.mesh.R import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.Node +import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.ui.supportsQrCodeSharing @Suppress("LongMethod") @@ -49,6 +50,12 @@ fun NodeMenu( onAction: (NodeMenuAction) -> Unit, firmwareVersion: String? = null, ) { + val isUnmessageable = if (node.user.hasIsUnmessagable()) { + node.user.isUnmessagable + } else { + // for older firmwares + node.user.role?.isUnmessageableRole() == true + } var displayFavoriteDialog by remember { mutableStateOf(false) } var displayIgnoreDialog by remember { mutableStateOf(false) } var displayRemoveDialog by remember { mutableStateOf(false) } @@ -104,13 +111,15 @@ fun NodeMenu( ) { if (showFullMenu) { - DropdownMenuItem( - onClick = { - onDismissRequest() - onAction(NodeMenuAction.DirectMessage(node)) - }, - text = { Text(stringResource(R.string.direct_message)) } - ) + if (!isUnmessageable) { + DropdownMenuItem( + onClick = { + onDismissRequest() + onAction(NodeMenuAction.DirectMessage(node)) + }, + text = { Text(stringResource(R.string.direct_message)) } + ) + } DropdownMenuItem( onClick = { onDismissRequest() diff --git a/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt b/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt index 17541b9de..166fdde62 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.ui.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.DeviceMetrics.Companion.currentTime import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.deviceMetrics @@ -37,6 +38,7 @@ class NodePreviewParameterProvider : PreviewParameterProvider { longName = "Mickey Mouse" shortName = "MM" hwModel = MeshProtos.HardwareModel.TBEAM + role = ConfigProtos.Config.DeviceConfig.Role.ROUTER }, position = position { latitudeI = 338125110 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4609c1c3f..193b68389 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -613,4 +613,7 @@ Scan QR Code Share Contact Import Shared Contact? + Include Unmessageable + Unmessageable + Unmonitored or Infrastructure