feat: Unmessageable (#1858)

This commit is contained in:
James Rich 2025-05-20 16:05:40 -05:00 committed by GitHub
parent f74855be95
commit 87076321ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 130 additions and 10 deletions

View file

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

View file

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

View file

@ -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<List<Node>> = 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(

View file

@ -162,6 +162,7 @@ private fun ContactItemPreview() {
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
),
selected = false,
)

View file

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

View file

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

View file

@ -95,6 +95,8 @@ fun NodeScreen(
onToggleIncludeUnknown = model::toggleIncludeUnknown,
showDetails = state.showDetails,
onToggleShowDetails = model::toggleShowDetails,
includeUnmessageable = state.includeUnmessageable,
onToggleIncludeUnmessageable = model::toggleIncludeUnmessageable
)
}

View file

@ -112,6 +112,7 @@ private fun ShareScreenPreview() {
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
),
),
onConfirm = {},

View file

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

View file

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

View file

@ -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<Node> {
longName = "Mickey Mouse"
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
},
position = position {
latitudeI = 338125110