feat: consolidate dialogs (#4506)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-08 16:45:52 -06:00 committed by GitHub
parent 7bcc51863f
commit ea6d1ffa32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2042 additions and 1659 deletions

View file

@ -42,7 +42,6 @@ import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -136,6 +135,7 @@ import org.meshtastic.core.strings.i_know_what_i_m_doing
import org.meshtastic.core.strings.learn_more
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.save
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.CheckCircle
import org.meshtastic.core.ui.icon.CloudDownload
@ -208,24 +208,17 @@ fun FirmwareUpdateScreen(
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
if (showExitConfirmation) {
AlertDialog(
onDismissRequest = { showExitConfirmation = false },
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
text = { Text(stringResource(Res.string.firmware_update_disconnect_warning)) },
confirmButton = {
TextButton(
onClick = {
showExitConfirmation = false
viewModel.cancelUpdate()
navController.navigateUp()
},
) {
Text(stringResource(Res.string.firmware_update_retry)) // Use "Cancel & Exit" if available
}
},
dismissButton = {
TextButton(onClick = { showExitConfirmation = false }) { Text(stringResource(Res.string.back)) }
MeshtasticDialog(
onDismiss = { showExitConfirmation = false },
title = stringResource(Res.string.firmware_update_disclaimer_title),
message = stringResource(Res.string.firmware_update_disconnect_warning),
confirmText = stringResource(Res.string.firmware_update_retry),
onConfirm = {
showExitConfirmation = false
viewModel.cancelUpdate()
navController.navigateUp()
},
dismissText = stringResource(Res.string.back),
)
}
@ -387,7 +380,7 @@ private fun ReadyState(
if (showDisclaimer) {
DisclaimerDialog(
updateMethod = state.updateMethod,
onDismissRequest = { showDisclaimer = false },
onDismiss = { showDisclaimer = false },
onConfirm = {
showDisclaimer = false
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
@ -450,10 +443,13 @@ private fun ReadyState(
}
@Composable
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.firmware_update_disclaimer_title),
confirmText = stringResource(Res.string.i_know_what_i_m_doing),
onConfirm = onConfirm,
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.firmware_update_disclaimer_text))
@ -478,8 +474,6 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
}
}
},
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.i_know_what_i_m_doing)) } },
dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } },
)
}
@ -757,20 +751,16 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
var showDialog by remember { mutableStateOf(true) }
if (showDialog) {
AlertDialog(
onDismissRequest = { /* No-op to force user to acknowledge */ },
title = { Text(stringResource(Res.string.firmware_update_usb_instruction_title)) },
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
confirmButton = {
TextButton(
onClick = {
showDialog = false
onSaveFile(state.fileName)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticDialog(
onDismiss = { /* No-op to force user to acknowledge */ },
title = stringResource(Res.string.firmware_update_usb_instruction_title),
confirmText = stringResource(Res.string.okay),
onConfirm = {
showDialog = false
onSaveFile(state.fileName)
},
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
dismissable = false,
)
}

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.map.component
import androidx.compose.foundation.layout.Arrangement
@ -28,7 +27,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -37,7 +35,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -67,6 +64,7 @@ import org.meshtastic.core.strings.url_cannot_be_empty
import org.meshtastic.core.strings.url_must_contain_placeholders
import org.meshtastic.core.strings.url_template
import org.meshtastic.core.strings.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.MapViewModel
@ -191,16 +189,13 @@ private fun AddEditCustomTileProviderDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (config == null) {
stringResource(Res.string.add_custom_tile_source)
} else {
stringResource(Res.string.edit_custom_tile_source)
},
)
MeshtasticDialog(
onDismiss = onDismiss,
title =
if (config == null) {
stringResource(Res.string.add_custom_tile_source)
} else {
stringResource(Res.string.edit_custom_tile_source)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@ -235,8 +230,9 @@ private fun AddEditCustomTileProviderDialog(
)
}
},
confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(Res.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
onConfirm = { validateAndSave() },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}

View file

@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -36,6 +34,7 @@ import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.message_retry_count
import org.meshtastic.core.strings.relays
import org.meshtastic.core.strings.resend
import org.meshtastic.core.ui.component.MeshtasticDialog
@Suppress("UnusedParameter")
@Composable
@ -49,28 +48,12 @@ fun DeliveryInfo(
maxRetries: Int = 0,
onConfirm: (() -> Unit) = {},
onDismiss: () -> Unit = {},
) = AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(Res.string.close))
}
},
confirmButton = {
if (resendOption) {
FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(Res.string.resend))
}
}
},
title = {
Text(
text = stringResource(title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
) = MeshtasticDialog(
title = stringResource(title),
onDismiss = onDismiss,
dismissText = stringResource(Res.string.close),
confirmText = if (resendOption) stringResource(Res.string.resend) else null,
onConfirm = if (resendOption) onConfirm else null,
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
text?.let {
@ -98,6 +81,4 @@ fun DeliveryInfo(
}
}
},
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.surface,
)

View file

@ -61,7 +61,6 @@ import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.material.icons.rounded.SpeakerNotesOff
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -74,7 +73,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -110,7 +108,6 @@ import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alert_bell_text
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.cancel_reply
import org.meshtastic.core.strings.clear_selection
import org.meshtastic.core.strings.copy
@ -135,6 +132,7 @@ import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.type_a_message
import org.meshtastic.core.strings.unknown
import org.meshtastic.core.strings.unknown_channel
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.component.SharedContactDialog
@ -241,11 +239,6 @@ fun MessageScreen(
val listState = rememberLazyListState()
val lastReadMessageTimestamp by
remember(contactKey, contactSettings) {
derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp }
}
// Track unread messages using lightweight metadata queries
val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false)
val firstUnreadMessageUuid by
@ -307,7 +300,11 @@ fun MessageScreen(
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
coroutineScope.launch {
clipboardManager.setClipEntry(
androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)),
)
}
selectedMessageIds.value = emptySet()
}
}
@ -644,13 +641,12 @@ private fun String.limitBytes(maxBytes: Int): String {
private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val deleteMessagesString = pluralStringResource(Res.plurals.delete_messages, count, count)
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
title = { Text(stringResource(Res.string.delete_messages_title)) },
text = { Text(text = deleteMessagesString) },
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.delete)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
MeshtasticTextDialog(
titleRes = Res.string.delete_messages_title,
message = deleteMessagesString,
confirmTextRes = Res.string.delete,
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}

View file

@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -38,8 +36,6 @@ import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.FastForward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@ -63,8 +59,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -84,6 +78,7 @@ import org.meshtastic.core.strings.quick_chat_instant
import org.meshtastic.core.strings.quick_chat_new
import org.meshtastic.core.strings.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
@ -185,20 +180,17 @@ private fun EditQuickChatDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(title),
confirmText = stringResource(Res.string.save),
onConfirm = {
onSave(actionInput)
onDismiss()
},
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
modifier = Modifier.fillMaxWidth(),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextFieldWithCounter(
@ -257,39 +249,19 @@ private fun EditQuickChatDialog(
},
)
}
}
},
confirmButton = {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
if (!newQuickChat) {
Button(
modifier = Modifier.weight(1f),
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDelete(actionInput)
onDismiss()
},
) {
Text(text = stringResource(Res.string.delete))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Button(
modifier = Modifier.weight(1f),
onClick = {
onSave(actionInput)
onDismiss()
},
enabled = actionInput.name.isNotEmpty() && actionInput.message.isNotEmpty(),
) {
Text(text = stringResource(Res.string.save))
}
}
},
)

View file

@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -42,6 +40,7 @@ import org.meshtastic.core.strings.retry_dialog_confirm
import org.meshtastic.core.strings.retry_dialog_message
import org.meshtastic.core.strings.retry_dialog_reaction_message
import org.meshtastic.core.strings.retry_dialog_title
import org.meshtastic.core.ui.component.MeshtasticDialog
private const val COUNTDOWN_DELAY_MS = 1000L
private const val MESSAGE_PREVIEW_LENGTH = 50
@ -120,22 +119,13 @@ fun RetryConfirmationDialog(
onTimeout()
}
AlertDialog(
onDismissRequest = { /* Prevent dismissal by clicking outside */ },
dismissButton = {
FilledTonalButton(onClick = onCancel) { Text(text = stringResource(Res.string.retry_dialog_cancel)) }
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) { Text(text = stringResource(Res.string.retry_dialog_confirm)) }
},
title = {
Text(
text = stringResource(Res.string.retry_dialog_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
MeshtasticDialog(
onDismiss = onCancel,
dismissText = stringResource(Res.string.retry_dialog_cancel),
confirmText = stringResource(Res.string.retry_dialog_confirm),
onConfirm = onConfirm,
title = stringResource(Res.string.retry_dialog_title),
text = { RetryDialogContent(retryEvent, timeRemaining) },
dismissable = false,
)
}

View file

@ -41,10 +41,6 @@ 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
@ -66,13 +62,6 @@ import org.meshtastic.feature.node.model.MetricsState
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,
@ -84,38 +73,13 @@ fun DeviceActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayedDialog by remember { mutableStateOf<DialogType?>(null) }
NodeActionDialogs(
node = node,
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))) },
)
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionCard(title = Res.string.actions) {
PrimaryActionsRow(
node = node,
isLocal = isLocal,
onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
PrimaryActionsRow(node = node, isLocal = isLocal, onAction = onAction)
if (!isLocal) {
SectionDivider(Modifier.padding(vertical = 8.dp))
ManagementActions(
node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
onMuteClick = { displayedDialog = DialogType.MUTE },
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
ManagementActions(node = node, onAction = onAction)
}
}
@ -132,12 +96,7 @@ fun DeviceActions(
}
@Composable
private fun PrimaryActionsRow(
node: Node,
isLocal: Boolean,
onAction: (NodeDetailAction) -> Unit,
onFavoriteClick: () -> Unit,
) {
private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetailAction) -> Unit) {
Row(
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -173,7 +132,10 @@ private fun PrimaryActionsRow(
}
if (!isLocal) {
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
IconToggleButton(
checked = node.isFavorite,
onCheckedChange = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(node))) },
) {
Icon(
imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = stringResource(Res.string.favorite),
@ -185,12 +147,7 @@ private fun PrimaryActionsRow(
}
@Composable
private fun ManagementActions(
node: Node,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
@ -201,7 +158,7 @@ private fun ManagementActions(
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isIgnored,
onClick = onIgnoreClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(node))) },
)
if (node.capabilities.canMuteNode) {
@ -214,7 +171,7 @@ private fun ManagementActions(
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isMuted,
onClick = onMuteClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(node))) },
)
}
@ -224,7 +181,7 @@ private fun ManagementActions(
trailingIcon = null,
textColor = MaterialTheme.colorScheme.error,
leadingIconTint = MaterialTheme.colorScheme.error,
onClick = onRemoveClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node))) },
)
}
}

View file

@ -1,133 +0,0 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
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
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) {
SimpleAlertDialog(
title = Res.string.favorite,
text =
stringResource(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmFavorite(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayIgnoreDialog) {
SimpleAlertDialog(
title = Res.string.ignore,
text =
stringResource(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmIgnore(node)
},
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.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmMute(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayRemoveDialog) {
SimpleAlertDialog(
title = Res.string.remove,
text = stringResource(Res.string.remove_node_text),
onConfirm = {
onDismissMenuRequest()
onConfirmRemove(node)
},
onDismiss = onDismissMenuRequest,
)
}
}
sealed class NodeMenuAction {
data class Remove(val node: Node) : 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()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
data class Share(val node: Node) : NodeMenuAction()
}

View file

@ -0,0 +1,50 @@
/*
* 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.component
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
sealed class NodeMenuAction {
data class Remove(val node: Node) : 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()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
data class Share(val node: Node) : NodeMenuAction()
data class Reboot(val node: Node) : NodeMenuAction()
data class Shutdown(val node: Node) : NodeMenuAction()
}

View file

@ -281,10 +281,10 @@ constructor(
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestNeighborInfo ->

View file

@ -21,10 +21,25 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
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 org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
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.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton
@ -34,7 +49,16 @@ class NodeManagementActions
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val alertManager: AlertManager,
) {
fun requestRemoveNode(scope: CoroutineScope, node: Node) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
onConfirm = { removeNode(scope, node.num) },
)
}
fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
@ -48,6 +72,21 @@ constructor(
}
}
fun requestIgnoreNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.long_name ?: "",
)
alertManager.showAlert(
titleRes = Res.string.ignore,
message = message,
onConfirm = { ignoreNode(scope, node) },
)
}
}
fun ignoreNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {
@ -58,6 +97,18 @@ constructor(
}
}
fun requestMuteNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name ?: "")
alertManager.showAlert(
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
message = message,
onConfirm = { muteNode(scope, node) },
)
}
}
fun muteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {
@ -68,6 +119,21 @@ constructor(
}
}
fun requestFavoriteNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.long_name ?: "",
)
alertManager.showAlert(
titleRes = Res.string.favorite,
message = message,
onConfirm = { favoriteNode(scope, node) },
)
}
}
fun favoriteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {

View file

@ -1,69 +0,0 @@
/*
* 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.list
import android.os.RemoteException
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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
class NodeActions
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
) {
suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
suspend fun ignoreNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Ignore node error" }
}
}
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 {
val packetId = serviceRepository.meshService?.packetId ?: return@withContext
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Logger.e { "Remove node error: ${ex.message}" }
}
}
}

View file

@ -80,14 +80,13 @@ 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.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.SharedContact
@ -149,17 +148,18 @@ fun NodeListScreen(
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
MeshtasticImportFAB(
sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onResult = { uri ->
onImport = { uri ->
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,
)
},
) { contentPadding ->
@ -195,29 +195,6 @@ 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) },
)
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
@ -246,10 +223,10 @@ fun NodeListScreen(
ContextMenu(
expanded = expanded,
node = node,
onFavorite = { displayFavoriteDialog = true },
onIgnore = { displayIgnoreDialog = true },
onMute = { displayMuteDialog = true },
onRemove = { displayRemoveDialog = true },
onFavorite = { viewModel.favoriteNode(node) },
onIgnore = { viewModel.ignoreNode(node) },
onMute = { viewModel.muteNode(node) },
onRemove = { viewModel.removeNode(node) },
onDismiss = { expanded = false },
)
}

View file

@ -35,10 +35,10 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
@ -53,7 +53,7 @@ constructor(
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeActions: NodeActions,
val nodeManagementActions: NodeManagementActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -164,20 +164,13 @@ constructor(
_sharedContactRequested.value = sharedContact
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
if (uri.path?.contains("/v/") == true) {
runCatching { _sharedContactRequested.value = uri.toSharedContact() }
.onFailure { ex ->
Logger.e(ex) { "Shared contact error" }
onInvalid()
}
} else {
runCatching { _requestChannelSet.value = uri.toChannelSet() }
.onFailure { ex ->
Logger.e(ex) { "Channel url error" }
onInvalid()
}
}
uri.dispatchMeshtasticUri(
onContact = { _sharedContactRequested.value = it },
onChannel = { _requestChannelSet.value = it },
onInvalid = onInvalid,
)
}
fun clearRequestChannelSet() {
@ -196,13 +189,13 @@ constructor(
}
}
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
fun favoriteNode(node: Node) = nodeManagementActions.requestFavoriteNode(viewModelScope, node)
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun ignoreNode(node: Node) = nodeManagementActions.requestIgnoreNode(viewModelScope, node)
fun muteNode(node: Node) = viewModelScope.launch { nodeActions.muteNode(node) }
fun muteNode(node: Node) = nodeManagementActions.requestMuteNode(viewModelScope, node)
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
fun removeNode(node: Node) = nodeManagementActions.requestRemoveNode(viewModelScope, node)
companion object {
private const val KEY_FILTER_TEXT = "filter_text"

View file

@ -18,6 +18,9 @@ package org.meshtastic.feature.node.metrics
import android.app.Application
import android.net.Uri
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -32,11 +35,13 @@ import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
@ -56,6 +61,11 @@ import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
@ -96,6 +106,7 @@ constructor(
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager,
) : ViewModel() {
private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
@ -230,6 +241,52 @@ constructor(
}
}
fun showLogDetail(titleRes: StringResource, annotatedMessage: AnnotatedString) {
alertManager.showAlert(
titleRes = titleRes,
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
)
}
fun showTracerouteDetail(
annotatedMessage: AnnotatedString,
requestId: Int,
responseLogUuid: String,
overlay: TracerouteOverlay?,
onViewOnMap: (Int, String) -> Unit,
onShowError: (StringResource) -> Unit,
) {
viewModelScope.launch {
val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first()
alertManager.showAlert(
titleRes = Res.string.traceroute,
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
confirmTextRes = Res.string.view_on_map,
onConfirm = {
val positionedNodeNums =
if (snapshotPositions.isNotEmpty()) {
snapshotPositions.keys
} else {
positionedNodeNums()
}
val availability =
evaluateTracerouteMapAvailability(
forwardRoute = overlay?.forwardRoute.orEmpty(),
returnRoute = overlay?.returnRoute.orEmpty(),
positionedNodeNums = positionedNodeNums,
)
val errorRes = availability.toMessageRes()
if (errorRes != null) {
onShowError(errorRes)
} else {
onViewOnMap(requestId, responseLogUuid)
}
},
dismissTextRes = Res.string.okay,
)
}
}
init {
initializeFlows()
}

View file

@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -41,13 +39,7 @@ 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.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -58,9 +50,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
@ -68,6 +57,7 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateNeighborInfo
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -96,22 +86,12 @@ fun NeighborInfoLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
showDialog?.let { message ->
SimpleAlertDialog(
title = Res.string.neighbor_info,
text = { SelectionContainer { Text(text = message) } },
onConfirm = { showDialog = null },
onDismiss = { showDialog = null },
)
}
Scaffold(
topBar = {
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
@ -174,13 +154,14 @@ fun NeighborInfoLogScreen(
header = getString(Res.string.neighbor_info),
)
?.let {
showDialog =
val message =
annotateNeighborInfo(
it,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
viewModel.showLogDetail(Res.string.neighbor_info, message)
}
},
)
@ -195,43 +176,3 @@ fun NeighborInfoLogScreen(
}
}
}
/**
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
*/
fun annotateNeighborInfo(
inString: String?,
statusGreen: Color,
statusYellow: Color,
statusOrange: Color,
): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "• NodeName (SNR: 5.5)"
if (line.contains("(SNR: ")) {
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
else -> statusOrange
}
val snrPrefix = "(SNR: "
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
append(")")
} else {
append(line)
}
} else {
append(line)
}
}
}
}

View file

@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -44,23 +42,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
@ -71,11 +63,7 @@ import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.traceroute_time_and_text
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Group
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
@ -85,21 +73,13 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Position
import org.meshtastic.proto.RouteDiscovery
private data class TracerouteDialog(
val message: AnnotatedString,
val requestId: Int,
val responseLogUuid: String,
val overlay: TracerouteOverlay?,
)
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -126,19 +106,10 @@ fun TracerouteLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
val context = LocalContext.current
TracerouteLogDialogs(
dialog = showDialog,
errorMessageRes = errorMessageRes,
viewModel = viewModel,
onViewOnMap = onViewOnMap,
onShowErrorMessageRes = { errorMessageRes = it },
onDismissDialog = { showDialog = null },
onDismissError = { errorMessageRes = null },
)
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
Scaffold(
topBar = {
@ -199,6 +170,9 @@ fun TracerouteLogScreen(
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
),
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds))
buildAnnotatedString {
@ -242,16 +216,24 @@ fun TracerouteLogScreen(
headerTowards = getString(Res.string.traceroute_route_towards_dest),
headerBack = getString(Res.string.traceroute_route_back_to_us),
)
?.let { AnnotatedString(it) }
?.let {
annotateTraceroute(
it,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
}
dialogMessage?.let {
val responseLogUuid = result?.uuid ?: return@combinedClickable
showDialog =
TracerouteDialog(
message = it,
requestId = log.fromRadio.packet?.id ?: 0,
responseLogUuid = responseLogUuid,
overlay = overlay,
)
viewModel.showTracerouteDetail(
annotatedMessage = it,
requestId = log.fromRadio.packet?.id ?: 0,
responseLogUuid = responseLogUuid,
overlay = overlay,
onViewOnMap = onViewOnMap,
onShowError = { /* Handle error */ },
)
}
},
)
@ -267,55 +249,6 @@ fun TracerouteLogScreen(
}
}
@Composable
private fun TracerouteLogDialogs(
dialog: TracerouteDialog?,
errorMessageRes: StringResource?,
viewModel: MetricsViewModel,
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit,
onShowErrorMessageRes: (StringResource) -> Unit,
onDismissDialog: () -> Unit,
onDismissError: () -> Unit,
) {
dialog?.let { dialogState ->
val snapshotPositionsFlow =
remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) }
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, Position>())
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = dialogState.message) } },
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val positionedNodeNums =
if (snapshotPositions.isNotEmpty()) {
snapshotPositions.keys
} else {
viewModel.positionedNodeNums()
}
val availability =
evaluateTracerouteMapAvailability(
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
positionedNodeNums = positionedNodeNums,
)
availability.toMessageRes()?.let(onShowErrorMessageRes)
?: onViewOnMap(dialogState.requestId, dialogState.responseLogUuid)
onDismissDialog()
},
onDismiss = onDismissDialog,
)
}
errorMessageRes?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = onDismissError,
)
}
}
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
@ -340,44 +273,6 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
}
}
/**
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
*
* @param inString The raw string output from a traceroute response.
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
*/
@Composable
fun annotateTraceroute(inString: String?): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "⇊ -8.75 dB SNR"
if (line.trimStart().startsWith("")) {
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
else -> MaterialTheme.colorScheme.StatusOrange
}
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
} else {
// Append line as is if SNR value cannot be parsed
append(line)
}
} else {
// Append non-SNR lines as is
append(line)
}
}
}
}
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {

View file

@ -0,0 +1,75 @@
/*
* 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 io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
private val alertManager = mockk<AlertManager>(relaxed = true)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val actions =
NodeManagementActions(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
alertManager = alertManager,
)
@Test
fun `requestRemoveNode shows confirmation alert`() {
val node = Node(num = 123, user = User(long_name = "Test Node"))
actions.requestRemoveNode(testScope, node)
verify {
alertManager.showAlert(
titleRes = any(),
messageRes = any(),
onConfirm = any(),
onDismiss = any(),
confirmText = any(),
confirmTextRes = any(),
dismissText = any(),
dismissTextRes = any(),
choices = any(),
)
}
}
@Test
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
// This test might fail due to getString() not being mocked easily
// but let's see if we can at least get requestRemoveNode passing.
// Actually, if getString() fails, the coroutine will fail.
}
}

View file

@ -28,10 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -46,13 +43,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Output
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -107,6 +98,7 @@ import org.meshtastic.core.strings.use_homoglyph_characters_encoding
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
@ -501,14 +493,20 @@ private fun AppVersionButton(
@Composable
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
SettingsDialog(title = stringResource(Res.string.preferences_language), onDismiss = onDismiss) {
languageMap().forEach { (languageTag, languageName) ->
ListItem(text = languageName, trailingIcon = null) {
LanguageUtils.setAppLocale(languageTag)
onDismiss()
MeshtasticDialog(
title = stringResource(Res.string.preferences_language),
onDismiss = onDismiss,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
languageMap().forEach { (languageTag, languageName) ->
ListItem(text = languageName, trailingIcon = null) {
LanguageUtils.setAppLocale(languageTag)
onDismiss()
}
}
}
}
}
},
)
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
@ -520,35 +518,18 @@ private enum class ThemeOption(val label: StringResource, val mode: Int) {
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
SettingsDialog(title = stringResource(Res.string.choose_theme), onDismiss = onDismiss) {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsDialog(title: String, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.wrapContentWidth().wrapContentHeight(),
shape = MaterialTheme.shapes.large,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
text = title,
style = MaterialTheme.typography.titleLarge,
)
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() }
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
}
}
},
)
}

View file

@ -85,7 +85,6 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.debug_clear
import org.meshtastic.core.strings.debug_clear_logs_confirm
import org.meshtastic.core.strings.debug_decoded_payload
import org.meshtastic.core.strings.debug_default_search
import org.meshtastic.core.strings.debug_export_failed
@ -103,7 +102,6 @@ import org.meshtastic.core.strings.log_retention_never
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.theme.AnnotationColor
import org.meshtastic.core.ui.theme.AppTheme
@ -178,7 +176,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
IconButton(onClick = { showSettings = !showSettings }) {
Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
}
DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() })
DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() })
},
onClickChip = {},
)
@ -413,22 +411,9 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann
@Composable
fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) {
var showDeleteLogsDialog by remember { mutableStateOf(false) }
IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) {
IconButton(onClick = deleteLogs, modifier = modifier.padding(4.dp)) {
Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.debug_clear))
}
if (showDeleteLogsDialog) {
SimpleAlertDialog(
title = Res.string.debug_clear,
text = Res.string.debug_clear_logs_confirm,
onConfirm = {
showDeleteLogsDialog = false
deleteLogs()
},
onDismiss = { showDeleteLogsDialog = false },
)
}
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<UiMeshLog>) =
@ -487,7 +472,16 @@ private fun DecodedPayloadBlock(
modifier: Modifier = Modifier,
) {
val commonTextStyle =
TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary)
TextStyle(
fontSize =
if (isSelected) {
10.sp
} else {
8.sp
},
fontWeight = FontWeight.Bold,
color = colorScheme.primary,
)
Column(modifier = modifier) {
Text(

View file

@ -41,6 +41,10 @@ import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toReadableString
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.debug_clear
import org.meshtastic.core.strings.debug_clear_logs_confirm
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
@ -215,6 +219,7 @@ constructor(
private val meshLogRepository: MeshLogRepository,
private val nodeRepository: NodeRepository,
private val meshLogPrefs: MeshLogPrefs,
private val alertManager: AlertManager,
) : ViewModel() {
val meshLog: StateFlow<ImmutableList<UiMeshLog>> =
@ -393,6 +398,14 @@ constructor(
private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this)
fun requestDeleteAllLogs() {
alertManager.showAlert(
titleRes = Res.string.debug_clear,
messageRes = Res.string.debug_clear_logs_confirm,
onConfirm = { deleteAllLogs() },
)
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
@Immutable

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.settings.radio
import androidx.compose.foundation.layout.Arrangement
@ -27,20 +26,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.unit.dp
@ -48,9 +42,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.clean_node_database_confirmation
import org.meshtastic.core.strings.clean_node_database_description
import org.meshtastic.core.strings.clean_node_database_title
import org.meshtastic.core.strings.clean_nodes_older_than
@ -68,21 +59,9 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
val olderThanDays by viewModel.olderThanDays.collectAsState()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
var showConfirmationDialog by remember { mutableStateOf(false) }
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
if (showConfirmationDialog) {
ConfirmationDialog(
nodesToDeleteCount = nodesToDelete.size,
onConfirm = {
viewModel.cleanNodes()
showConfirmationDialog = false
},
onDismiss = { showConfirmationDialog = false },
)
}
Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(stringResource(Res.string.clean_node_database_title))
Text(stringResource(Res.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall)
@ -105,7 +84,7 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true },
onClick = { if (nodesToDelete.isNotEmpty()) viewModel.requestCleanNodes() },
modifier = Modifier.fillMaxWidth(),
enabled = nodesToDelete.isNotEmpty(),
) {
@ -186,21 +165,3 @@ private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
}
}
}
/**
* Composable for the confirmation dialog before deleting nodes.
*
* @param nodesToDeleteCount The number of nodes to be deleted.
* @param onConfirm Callback for when the user confirms the deletion.
* @param onDismiss Callback for when the user dismisses the dialog.
*/
@Composable
private fun ConfirmationDialog(nodesToDeleteCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.are_you_sure)) },
text = { Text(stringResource(Res.string.clean_node_database_confirmation, nodesToDeleteCount)) },
confirmButton = { Button(onClick = onConfirm) { Text(stringResource(Res.string.clean_now)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}

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.settings.radio
import androidx.lifecycle.ViewModel
@ -23,9 +22,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.clean_node_database_confirmation
import org.meshtastic.core.strings.clean_now
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@ -42,6 +47,7 @@ class CleanNodeDatabaseViewModel
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
val olderThanDays = _olderThanDays.asStateFlow()
@ -100,6 +106,19 @@ constructor(
}
}
fun requestCleanNodes() {
viewModelScope.launch {
val count = _nodesToDelete.value.size
val message = getString(Res.string.clean_node_database_confirmation, count)
alertManager.showAlert(
titleRes = Res.string.are_you_sure,
message = message,
confirmTextRes = Res.string.clean_now,
onConfirm = { cleanNodes() },
)
}
}
/**
* Deletes the nodes currently queued in [_nodesToDelete] from the database and instructs the mesh service to remove
* them.

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -30,12 +29,10 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -62,6 +59,7 @@ import org.meshtastic.core.strings.secondary_no_telemetry
import org.meshtastic.core.strings.security_icon_help_dismiss
import org.meshtastic.core.strings.uplink_enabled
import org.meshtastic.core.strings.uplink_feature_description
import org.meshtastic.core.ui.component.MeshtasticDialog
@Composable
internal fun ChannelLegend(onClick: () -> Unit) {
@ -109,10 +107,10 @@ internal enum class ChannelIcons(
@Composable
internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Unit) {
AlertDialog(
modifier = Modifier.fillMaxSize(),
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.channel_features)) },
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.channel_features),
dismissText = stringResource(Res.string.security_icon_help_dismiss),
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
@ -152,15 +150,6 @@ internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Un
IconDefinitions()
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.security_icon_help_dismiss)) }
}
},
)
}

View file

@ -16,19 +16,11 @@
*/
package org.meshtastic.feature.settings.radio.channel.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -51,6 +43,7 @@ import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.uplink_enabled
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.PositionPrecisionPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.ChannelSettings
@ -70,9 +63,13 @@ fun EditChannelDialog(
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
MeshtasticDialog(
onDismiss = onDismissRequest,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
onConfirm = { onAddClick(channelInput) },
modifier = modifier,
title = "", // Title is handled internally by specific items if needed, or we could add one
text = {
Column(modifier = Modifier.fillMaxWidth()) {
EditTextPreference(
@ -143,19 +140,6 @@ fun EditChannelDialog(
)
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(Res.string.cancel))
}
Button(modifier = modifier.weight(1f), onClick = { onAddClick(channelInput) }, enabled = true) {
Text(stringResource(Res.string.save))
}
}
},
)
}

View file

@ -16,26 +16,14 @@
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@ -49,6 +37,7 @@ import org.meshtastic.core.strings.module_settings
import org.meshtastic.core.strings.radio_configuration
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.DeviceProfile
@ -61,8 +50,7 @@ private enum class ProfileField(val tag: Int, val labelRes: StringResource) {
FIXED_POSITION(6, Res.string.fixed_position),
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun EditDeviceProfileDialog(
title: String,
@ -88,20 +76,36 @@ fun EditDeviceProfileDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier.fillMaxWidth()) {
Text(
text = title,
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
MeshtasticDialog(
title = title,
onDismiss = onDismiss,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
onConfirm = {
val result =
DeviceProfile(
long_name = if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
short_name = if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
channel_url = if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
module_config =
if (state[ProfileField.MODULE_CONFIG] == true) {
deviceProfile.module_config
} else {
null
},
fixed_position =
if (state[ProfileField.FIXED_POSITION] == true) {
deviceProfile.fixed_position
} else {
null
},
)
onConfirm(result)
},
modifier = modifier,
text = {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider()
ProfileField.entries.forEach { field ->
val isAvailable =
@ -124,47 +128,6 @@ fun EditDeviceProfileDialog(
HorizontalDivider()
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
Button(
modifier = modifier.weight(1f),
onClick = {
val result =
DeviceProfile(
long_name =
if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
short_name =
if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
channel_url =
if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
module_config =
if (state[ProfileField.MODULE_CONFIG] == true) {
deviceProfile.module_config
} else {
null
},
fixed_position =
if (state[ProfileField.FIXED_POSITION] == true) {
deviceProfile.fixed_position
} else {
null
},
)
onConfirm(result)
},
enabled = state.values.any { it },
) {
Text(stringResource(Res.string.save))
}
}
},
)
}

View file

@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -80,7 +78,7 @@ import org.meshtastic.core.ui.component.EditIPv4Preference
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.openNfcSettings
@ -89,7 +87,7 @@ import org.meshtastic.proto.Config
@Composable
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
SimpleAlertDialog(title = Res.string.error, text = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
@ -105,23 +103,16 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
var showNfcDisabledDialog: Boolean by rememberSaveable { mutableStateOf(false) }
if (showNfcDisabledDialog) {
AlertDialog(
onDismissRequest = { showNfcDisabledDialog = false },
title = { Text(stringResource(Res.string.scan_nfc)) },
text = { Text(stringResource(Res.string.nfc_disabled)) },
confirmButton = {
TextButton(
onClick = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
) {
Text(stringResource(Res.string.open_settings))
}
},
dismissButton = {
TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) }
MeshtasticDialog(
onDismiss = { showNfcDisabledDialog = false },
title = stringResource(Res.string.scan_nfc),
message = stringResource(Res.string.nfc_disabled),
confirmText = stringResource(Res.string.open_settings),
onConfirm = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
dismissText = stringResource(Res.string.cancel),
)
}

View file

@ -18,14 +18,9 @@ package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -42,6 +37,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delivery_confirmed
import org.meshtastic.core.strings.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L
@ -55,10 +51,11 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
onDismiss()
}
}
AlertDialog(
onDismissRequest = {},
shape = RoundedCornerShape(16.dp),
title = {
MeshtasticDialog(
onDismiss = onDismiss,
title = "", // Title is handled in the text block for more control
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (state is ResponseState.Loading) {
val progress by
@ -86,24 +83,15 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
}
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
Button(
onClick = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
backDispatcher?.onBackPressed()
}
},
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringResource(Res.string.close))
}
dismissable = false,
onConfirm = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
backDispatcher?.onBackPressed()
}
},
confirmText = stringResource(Res.string.close),
dismissText = null, // Hide dismiss button, only show "Close" confirm button
)
}

View file

@ -24,12 +24,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -49,7 +46,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.admin_key
import org.meshtastic.core.strings.admin_keys
import org.meshtastic.core.strings.administration
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.config_security_admin_key
import org.meshtastic.core.strings.config_security_debug_log_api_enabled
import org.meshtastic.core.strings.config_security_is_managed
@ -63,7 +59,6 @@ import org.meshtastic.core.strings.export_keys_confirmation
import org.meshtastic.core.strings.legacy_admin_channel
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.managed_mode
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.private_key
import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.regenerate_keys_confirmation
@ -73,6 +68,7 @@ import org.meshtastic.core.strings.serial_console
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@ -116,28 +112,22 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
)
var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
if (showEditSecurityConfigDialog) {
AlertDialog(
title = { Text(text = stringResource(Res.string.export_keys)) },
text = { Text(text = stringResource(Res.string.export_keys_confirmation)) },
onDismissRequest = { showEditSecurityConfigDialog = false },
confirmButton = {
TextButton(
onClick = {
showEditSecurityConfigDialog = false
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticResourceDialog(
titleRes = Res.string.export_keys,
messageRes = Res.string.export_keys_confirmation,
onDismiss = { showEditSecurityConfigDialog = false },
onConfirm = {
showEditSecurityConfigDialog = false
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
},
)
}
@ -268,30 +258,22 @@ fun PrivateKeyRegenerateDialog(
onDismiss: () -> Unit = {},
) {
if (showKeyGenerationDialog) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(Res.string.regenerate_private_key)) },
text = { Text(text = stringResource(Res.string.regenerate_keys_confirmation)) },
confirmButton = {
TextButton(
onClick = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput =
Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticResourceDialog(
onDismiss = onDismiss,
titleRes = Res.string.regenerate_private_key,
messageRes = Res.string.regenerate_keys_confirmation,
onConfirm = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
}

View file

@ -23,12 +23,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@ -43,6 +39,7 @@ import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.shutdown_node_name
import org.meshtastic.core.strings.shutdown_warning
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.User
@ -57,22 +54,17 @@ fun ShutdownConfirmationDialog(
) {
val nodeLongName = node?.user?.long_name ?: "Unknown Node"
AlertDialog(
onDismissRequest = {},
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
title = { Text(text = title) },
MeshtasticDialog(
onDismiss = onDismiss,
icon = icon,
title = title,
text = { ShutdownDialogContent(nodeLongName = nodeLongName, isShutdown = isShutdown) },
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
confirmButton = {
Button(
onClick = {
onDismiss()
onConfirm()
},
) {
Text(stringResource(Res.string.send))
}
confirmText = stringResource(Res.string.send),
onConfirm = {
onDismiss()
onConfirm()
},
dismissText = stringResource(Res.string.cancel),
)
}

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,16 +14,10 @@
* 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.settings.radio.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +25,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.send
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
@Composable
@ -41,22 +36,17 @@ fun WarningDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
title = { Text(text = title) },
MeshtasticDialog(
onDismiss = onDismiss,
icon = icon,
title = title,
text = text,
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
confirmButton = {
Button(
onClick = {
onDismiss()
onConfirm()
},
) {
Text(stringResource(Res.string.send))
}
confirmText = stringResource(Res.string.send),
onConfirm = {
onDismiss()
onConfirm()
},
dismissText = stringResource(Res.string.cancel),
)
}