feat: Add mute node functionality (#4181)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-10 15:35:01 -06:00 committed by GitHub
parent 42fe7e9b2e
commit a67b519abd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2174 additions and 458 deletions

View file

@ -0,0 +1,261 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
fun DeviceActions(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayMuteDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayMuteDialog = displayMuteDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayMuteDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
DeviceActionsContent(
node = node,
isLocal = isLocal,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
onAction = onAction,
onFavoriteClick = { displayFavoriteDialog = true },
onIgnoreClick = { displayIgnoreDialog = true },
onMuteClick = { displayMuteDialog = true },
onRemoveClick = { displayRemoveDialog = true },
)
}
}
@Composable
private fun DeviceActionsContent(
node: Node,
isLocal: Boolean,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
onAction: (NodeDetailAction) -> Unit,
onFavoriteClick: () -> Unit,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
Column(modifier = Modifier.padding(vertical = 12.dp)) {
Text(
text = stringResource(Res.string.actions),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick)
if (!isLocal) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
RemoteDeviceActions(
node = node,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
onAction = onAction,
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ManagementActions(node, onIgnoreClick, onMuteClick, onRemoveClick)
}
}
@Composable
private fun PrimaryActionsRow(
node: Node,
isLocal: Boolean,
onAction: (NodeDetailAction) -> Unit,
onFavoriteClick: () -> Unit,
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!node.isEffectivelyUnmessageable && !isLocal) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.direct_message))
}
}
OutlinedButton(
onClick = { onAction(NodeDetailAction.ShareContact) },
modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
shape = MaterialTheme.shapes.large,
) {
Icon(Icons.Rounded.QrCode2, contentDescription = null)
if (node.isEffectivelyUnmessageable || isLocal) {
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.share_contact))
}
}
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
Icon(
imageVector = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = stringResource(Res.string.favorite),
tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
)
}
}
}
@Composable
private fun ManagementActions(
node: Node,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
leadingIcon =
if (node.isIgnored) {
Icons.AutoMirrored.Outlined.VolumeMute
} else {
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isIgnored,
onClick = onIgnoreClick,
)
SwitchListItem(
text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always),
leadingIcon = if (node.isMuted) {
Icons.AutoMirrored.Filled.VolumeOff
} else {
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isMuted,
onClick = onMuteClick,
)
ListItem(
text = stringResource(Res.string.remove),
leadingIcon = Icons.Rounded.Delete,
trailingIcon = null,
textColor = MaterialTheme.colorScheme.error,
leadingIconTint = MaterialTheme.colorScheme.error,
onClick = onRemoveClick,
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@ -26,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Star
@ -60,6 +60,7 @@ import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.component.ListItem
@ -67,6 +68,13 @@ import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
private enum class DialogType {
FAVORITE,
IGNORE,
MUTE,
REMOVE,
}
@Composable
fun DeviceActions(
node: Node,
@ -76,22 +84,18 @@ fun DeviceActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
var displayedDialog by remember { mutableStateOf<DialogType?>(null) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayRemoveDialog = false
},
displayFavoriteDialog = displayedDialog == DialogType.FAVORITE,
displayIgnoreDialog = displayedDialog == DialogType.IGNORE,
displayMuteDialog = displayedDialog == DialogType.MUTE,
displayRemoveDialog = displayedDialog == DialogType.REMOVE,
onDismissMenuRequest = { displayedDialog = null },
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
@ -101,21 +105,17 @@ fun DeviceActions(
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 12.dp)) {
Text(
text = stringResource(Res.string.actions),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
ActionsHeader()
PrimaryActionsRow(
node = node,
isLocal = isLocal,
onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick = { displayFavoriteDialog = true })
if (!isLocal) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ActionsDivider()
RemoteDeviceActions(
node = node,
@ -125,20 +125,37 @@ fun DeviceActions(
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ActionsDivider()
ManagementActions(
node = node,
onIgnoreClick = { displayIgnoreDialog = true },
onRemoveClick = { displayRemoveDialog = true },
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
onMuteClick = { displayedDialog = DialogType.MUTE },
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
}
}
}
@Composable
private fun ActionsHeader() {
Text(
text = stringResource(Res.string.actions),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
@Composable
private fun ActionsDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
@Composable
private fun PrimaryActionsRow(
node: Node,
@ -191,7 +208,12 @@ private fun PrimaryActionsRow(
}
@Composable
private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveClick: () -> Unit) {
private fun ManagementActions(
node: Node,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
@ -205,6 +227,20 @@ private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveCli
onClick = onIgnoreClick,
)
if (node.capabilities.canMuteNode) {
SwitchListItem(
text = stringResource(Res.string.mute_notifications),
leadingIcon =
if (node.isMuted) {
Icons.AutoMirrored.Filled.VolumeOff
} else {
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isMuted,
onClick = onMuteClick,
)
}
ListItem(
text = stringResource(Res.string.remove),
leadingIcon = Icons.Rounded.Delete,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import android.content.res.Configuration
@ -80,6 +79,7 @@ fun NodeItem(
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val longName = thatNode.user.longName.ifEmpty { stringResource(Res.string.unknown_username) }
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
@ -145,6 +145,7 @@ fun NodeItem(
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
isMuted = isMuted,
isUnmessageable = unmessageable,
connectionState = connectionState,
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.runtime.Composable
@ -28,8 +27,12 @@ import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
import org.meshtastic.core.strings.mute_add
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.SimpleAlertDialog
@Composable
@ -37,10 +40,12 @@ fun NodeActionDialogs(
node: Node,
displayFavoriteDialog: Boolean,
displayIgnoreDialog: Boolean,
displayMuteDialog: Boolean,
displayRemoveDialog: Boolean,
onDismissMenuRequest: () -> Unit,
onConfirmFavorite: (Node) -> Unit,
onConfirmIgnore: (Node) -> Unit,
onConfirmMute: (Node) -> Unit,
onConfirmRemove: (Node) -> Unit,
) {
if (displayFavoriteDialog) {
@ -73,6 +78,18 @@ fun NodeActionDialogs(
onDismiss = onDismissMenuRequest,
)
}
if (displayMuteDialog) {
SimpleAlertDialog(
title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
text =
stringResource(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.longName),
onConfirm = {
onDismissMenuRequest()
onConfirmMute(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayRemoveDialog) {
SimpleAlertDialog(
title = Res.string.remove,
@ -91,6 +108,8 @@ sealed class NodeMenuAction {
data class Ignore(val node: Node) : NodeMenuAction()
data class Mute(val node: Node) : NodeMenuAction()
data class Favorite(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.rounded.NoCell
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.twotone.Cloud
@ -39,8 +39,11 @@ import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
@ -49,6 +52,7 @@ import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
@ -56,102 +60,135 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
isMuted: Boolean,
connectionState: ConnectionState,
) {
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
stringResource(
when (connectionState) {
ConnectionState.Connected -> Res.string.connected
ConnectionState.Connecting -> Res.string.connecting
ConnectionState.Disconnected -> Res.string.disconnected
ConnectionState.DeviceSleep -> Res.string.device_sleeping
},
),
)
}
},
state = rememberTooltipState(),
) {
when (connectionState) {
ConnectionState.Connected -> {
Icon(
imageVector = Icons.TwoTone.CloudDone,
contentDescription = stringResource(Res.string.connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusGreen,
)
}
ConnectionState.Connecting -> {
Icon(
imageVector = Icons.TwoTone.CloudSync,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusOrange,
)
}
ConnectionState.Disconnected -> {
Icon(
imageVector = Icons.TwoTone.CloudOff,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusRed,
)
}
ConnectionState.DeviceSleep -> {
Icon(
imageVector = Icons.TwoTone.Cloud,
contentDescription = stringResource(Res.string.device_sleeping),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
}
}
ThisNodeStatusBadge(connectionState)
}
if (isUnmessageable) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(stringResource(Res.string.unmonitored_or_infrastructure)) } },
state = rememberTooltipState(),
) {
IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Rounded.NoCell,
contentDescription = stringResource(Res.string.unmessageable),
modifier = Modifier.size(24.dp), // Smaller size for badge
)
}
}
StatusBadge(
imageVector = Icons.Rounded.NoCell,
contentDescription = Res.string.unmessageable,
tooltipText = Res.string.unmonitored_or_infrastructure,
)
}
if (isMuted && !isThisNode) {
StatusBadge(
imageVector = Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = Res.string.mute_always,
tooltipText = Res.string.mute_always,
)
}
if (isFavorite && !isThisNode) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(stringResource(Res.string.favorite)) } },
state = rememberTooltipState(),
) {
IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Rounded.Star,
contentDescription = stringResource(Res.string.favorite),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
StatusBadge(
imageVector = Icons.Rounded.Star,
contentDescription = Res.string.favorite,
tooltipText = Res.string.favorite,
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
stringResource(
when (connectionState) {
ConnectionState.Connected -> Res.string.connected
ConnectionState.Connecting -> Res.string.connecting
ConnectionState.Disconnected -> Res.string.disconnected
ConnectionState.DeviceSleep -> Res.string.device_sleeping
},
),
)
}
},
state = rememberTooltipState(),
) {
when (connectionState) {
ConnectionState.Connected -> ConnectedStatusIcon()
ConnectionState.Connecting -> ConnectingStatusIcon()
ConnectionState.Disconnected -> DisconnectedStatusIcon()
ConnectionState.DeviceSleep -> DeviceSleepStatusIcon()
}
}
}
@Composable
private fun ConnectedStatusIcon() {
Icon(
imageVector = Icons.TwoTone.CloudDone,
contentDescription = stringResource(Res.string.connected),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusGreen,
)
}
@Composable
private fun ConnectingStatusIcon() {
Icon(
imageVector = Icons.TwoTone.CloudSync,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusOrange,
)
}
@Composable
private fun DisconnectedStatusIcon() {
Icon(
imageVector = Icons.TwoTone.CloudOff,
contentDescription = stringResource(Res.string.disconnected),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusRed,
)
}
@Composable
private fun DeviceSleepStatusIcon() {
Icon(
imageVector = Icons.TwoTone.Cloud,
contentDescription = stringResource(Res.string.device_sleeping),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatusBadge(
imageVector: ImageVector,
contentDescription: StringResource,
tooltipText: StringResource,
tint: Color = Color.Unspecified,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
state = rememberTooltipState(),
) {
IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = imageVector,
contentDescription = stringResource(contentDescription),
modifier = Modifier.size(24.dp),
tint = tint,
)
}
}
}
@ -163,6 +200,7 @@ private fun StatusIconsPreview() {
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
isMuted = true,
connectionState = ConnectionState.Connected,
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@ -80,10 +79,14 @@ internal fun RemoteDeviceActions(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
)
RequestNeighborsChip(
lastRequestNeighborsTime = lastRequestNeighborsTime,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) },
)
if (node.capabilities.canRequestNeighborInfo) {
RequestNeighborsChip(
lastRequestNeighborsTime = lastRequestNeighborsTime,
onClick = {
onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node)))
},
)
}
AssistChip(
onClick = {

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import kotlinx.coroutines.CoroutineScope
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.feature.node.component.NodeMenuAction
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NodeDetailActions
@Inject
constructor(
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
) {
private var scope: CoroutineScope? = null
fun start(coroutineScope: CoroutineScope) {
scope = coroutineScope
nodeManagementActions.start(coroutineScope)
nodeRequestActions.start(coroutineScope)
}
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
is NodeMenuAction.RequestNeighborInfo -> nodeRequestActions.requestNeighborInfo(action.node.num)
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
is NodeMenuAction.TraceRoute -> nodeRequestActions.requestTraceroute(action.node.num)
else -> {}
}
}
fun setNodeNotes(nodeNum: Int, notes: String) {
nodeManagementActions.setNodeNotes(nodeNum, notes)
}
fun requestPosition(destNum: Int, position: Position) {
nodeRequestActions.requestPosition(destNum, position)
}
fun requestUserInfo(destNum: Int) {
nodeRequestActions.requestUserInfo(destNum)
}
fun requestNeighborInfo(destNum: Int) {
nodeRequestActions.requestNeighborInfo(destNum)
}
fun requestTelemetry(destNum: Int, type: TelemetryType) {
nodeRequestActions.requestTelemetry(destNum, type)
}
fun requestTraceroute(destNum: Int) {
nodeRequestActions.requestTraceroute(destNum)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.foundation.layout.Box
@ -45,40 +44,40 @@ import org.meshtastic.feature.node.model.NodeDetailAction
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
LaunchedEffect(nodeId) { viewModel.setNodeId(nodeId) }
LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) }
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val availableLogs by
remember(state, environmentState) {
remember(metricsState, environmentMetricsState) {
derivedStateOf {
buildSet {
if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
if (state.hasPositionLogs()) {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
if (state.hasPowerMetrics()) add(LogsType.POWER)
if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (state.hasHostMetrics()) add(LogsType.HOST)
if (state.hasPaxMetrics()) add(LogsType.PAX)
if (environmentMetricsState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
}
}
val node = state.node
val node = metricsState.node
@Suppress("ModifierNotUsedAtRoot")
Scaffold(
@ -95,11 +94,10 @@ fun NodeDetailScreen(
},
) { paddingValues ->
if (node != null) {
@Suppress("ViewModelForwarding")
NodeDetailContent(
node = node,
ourNode = ourNode,
metricsState = state,
metricsState = metricsState,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
availableLogs = availableLogs,
@ -111,8 +109,8 @@ fun NodeDetailScreen(
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
handleNodeMenuAction = { nodeDetailViewModel.handleNodeMenuAction(it) },
metricsViewModel = metricsViewModel,
nodeDetailViewModel = nodeDetailViewModel,
)
},
modifier = modifier.padding(paddingValues),
@ -133,26 +131,26 @@ private fun handleNodeAction(
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: MetricsViewModel,
handleNodeMenuAction: (NodeMenuAction) -> Unit,
metricsViewModel: MetricsViewModel,
nodeDetailViewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("$channel${node.user.id}")
navigateToMessages("${channel}${node.user.id}")
}
is NodeMenuAction.Remove -> {
handleNodeMenuAction(menuAction)
nodeDetailViewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> handleNodeMenuAction(menuAction)
else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,25 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
import javax.inject.Inject
@ -41,9 +32,15 @@ class NodeDetailViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
) : ViewModel() {
init {
nodeManagementActions.start(viewModelScope)
nodeRequestActions.start(viewModelScope)
}
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
@ -54,107 +51,26 @@ constructor(
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> removeNode(action.node.num)
is NodeMenuAction.Ignore -> ignoreNode(action.node)
is NodeMenuAction.Favorite -> favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
is NodeMenuAction.RequestNeighborInfo -> {
requestNeighborInfo(action.node.num)
nodeRequestActions.requestNeighborInfo(action.node.num)
_lastRequestNeighborsTime.value = System.currentTimeMillis()
}
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
is NodeMenuAction.RequestTelemetry -> requestTelemetry(action.node.num, action.type)
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
is NodeMenuAction.TraceRoute -> {
requestTraceroute(action.node.num)
nodeRequestActions.requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
}
else -> {}
}
}
fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
Logger.e { "Set node notes IO error: ${ex.message}" }
} catch (ex: java.sql.SQLException) {
Logger.e { "Set node notes SQL error: ${ex.message}" }
}
}
private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Logger.e { "Remove node error: ${ex.message}" }
}
}
private fun ignoreNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Ignore node error" }
}
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
private fun requestUserInfo(destNum: Int) {
Logger.i { "Requesting UserInfo for '$destNum'" }
try {
serviceRepository.meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) {
Logger.e { "Request NodeInfo error: ${ex.message}" }
}
}
private fun requestNeighborInfo(destNum: Int) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
} catch (ex: RemoteException) {
Logger.e { "Request NeighborInfo error: ${ex.message}" }
}
}
private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
Logger.i { "Requesting position for '$destNum'" }
try {
serviceRepository.meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
Logger.e { "Request position error: ${ex.message}" }
}
}
private fun requestTelemetry(destNum: Int, type: TelemetryType) {
Logger.i { "Requesting telemetry for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
} catch (ex: RemoteException) {
Logger.e { "Request telemetry error: ${ex.message}" }
}
}
private fun requestTraceroute(destNum: Int) {
Logger.i { "Requesting traceroute for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) {
Logger.e { "Request traceroute error: ${ex.message}" }
}
fun setNodeNotes(nodeNum: Int, notes: String) {
nodeManagementActions.setNodeNotes(nodeNum, notes)
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.os.RemoteException
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NodeManagementActions
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
) {
private var scope: CoroutineScope? = null
fun start(coroutineScope: CoroutineScope) {
scope = coroutineScope
}
fun removeNode(nodeNum: Int) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Logger.e { "Remove node error: ${ex.message}" }
}
}
}
fun ignoreNode(node: Node) {
scope?.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Ignore node error" }
}
}
}
fun muteNode(node: Node) {
scope?.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Mute(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Mute node error" }
}
}
}
fun favoriteNode(node: Node) {
scope?.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
}
fun setNodeNotes(nodeNum: Int, notes: String) {
scope?.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
Logger.e { "Set node notes IO error: ${ex.message}" }
} catch (ex: java.sql.SQLException) {
Logger.e { "Set node notes SQL error: ${ex.message}" }
}
}
}
}

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.os.RemoteException
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
private var scope: CoroutineScope? = null
fun start(coroutineScope: CoroutineScope) {
scope = coroutineScope
}
fun requestUserInfo(destNum: Int) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" }
try {
serviceRepository.meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) {
Logger.e { "Request NodeInfo error: ${ex.message}" }
}
}
}
fun requestNeighborInfo(destNum: Int) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
} catch (ex: RemoteException) {
Logger.e { "Request NeighborInfo error: ${ex.message}" }
}
}
}
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" }
try {
serviceRepository.meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
Logger.e { "Request position error: ${ex.message}" }
}
}
}
fun requestTelemetry(destNum: Int, type: TelemetryType) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
} catch (ex: RemoteException) {
Logger.e { "Request telemetry error: ${ex.message}" }
}
}
}
fun requestTraceroute(destNum: Int) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) {
Logger.e { "Request traceroute error: ${ex.message}" }
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import android.os.RemoteException
@ -49,6 +48,14 @@ constructor(
}
}
suspend fun muteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Mute(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Mute node error" }
}
}
suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import androidx.compose.animation.core.animateFloatAsState
@ -30,6 +29,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.DoDisturbOn
import androidx.compose.material.icons.outlined.DoDisturbOn
import androidx.compose.material.icons.rounded.DeleteOutline
@ -63,22 +64,22 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_favorite
import org.meshtastic.core.strings.remove_ignored
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.component.supportsQrCodeSharing
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
@ -134,8 +135,7 @@ fun NodeListScreen(
)
},
floatingActionButton = {
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: AdminProtos.SharedContact? by
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
@ -183,20 +183,24 @@ fun NodeListScreen(
items(nodes, key = { it.num }) { node ->
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayMuteDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayMuteDialog = displayMuteDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayMuteDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = viewModel::favoriteNode,
onConfirmIgnore = viewModel::ignoreNode,
onConfirmMute = viewModel::muteNode,
onConfirmRemove = { viewModel.removeNode(it.num) },
)
@ -229,9 +233,10 @@ fun NodeListScreen(
ContextMenu(
expanded = expanded,
node = node,
onClickFavorite = { displayFavoriteDialog = true },
onClickIgnore = { displayIgnoreDialog = true },
onClickRemove = { displayRemoveDialog = true },
onFavorite = { displayFavoriteDialog = true },
onIgnore = { displayIgnoreDialog = true },
onMute = { displayMuteDialog = true },
onRemove = { displayRemoveDialog = true },
onDismiss = { expanded = false },
)
}
@ -247,69 +252,103 @@ fun NodeListScreen(
private fun ContextMenu(
expanded: Boolean,
node: Node,
onClickFavorite: (Node) -> Unit,
onClickIgnore: (Node) -> Unit,
onClickRemove: (Node) -> Unit,
onFavorite: () -> Unit,
onIgnore: () -> Unit,
onMute: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
val isFavorite = node.isFavorite
val isIgnored = node.isIgnored
DropdownMenuItem(
onClick = {
onClickFavorite(node)
onDismiss()
},
enabled = !isIgnored,
leadingIcon = {
Icon(
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = null,
)
},
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
)
DropdownMenuItem(
onClick = {
onClickIgnore(node)
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
color = MaterialTheme.colorScheme.StatusRed,
)
},
)
DropdownMenuItem(
onClick = {
onClickRemove(node)
onDismiss()
},
enabled = !isIgnored,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = if (isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(Res.string.remove),
color = if (isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
)
},
)
FavoriteMenuItem(node, onFavorite, onDismiss)
IgnoreMenuItem(node, onIgnore, onDismiss)
if (node.capabilities.canMuteNode) {
MuteMenuItem(node, onMute, onDismiss)
}
RemoveMenuItem(node, onRemove, onDismiss)
}
}
@Composable
private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
val isFavorite = node.isFavorite
DropdownMenuItem(
onClick = {
onFavorite()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = null,
)
},
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
)
}
@Composable
private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
val isIgnored = node.isIgnored
DropdownMenuItem(
onClick = {
onIgnore()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
color = MaterialTheme.colorScheme.StatusRed,
)
},
)
}
@Composable
private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
val isMuted = node.isMuted
DropdownMenuItem(
onClick = {
onMute()
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
)
},
text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
)
}
@Composable
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
DropdownMenuItem(
onClick = {
onRemove()
onDismiss()
},
enabled = !node.isIgnored,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(Res.string.remove),
color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
)
},
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
@ -39,7 +38,6 @@ import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ConfigProtos
import javax.inject.Inject
import kotlin.Boolean
@HiltViewModel
class NodeListViewModel
@ -161,6 +159,8 @@ constructor(
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun muteNode(node: Node) = viewModelScope.launch { nodeActions.muteNode(node) }
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
companion object {