From 40cfd8d15133fbd9c30a0589cee7082ef1134a70 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:12:58 -0500 Subject: [PATCH] fix(node): remove every unheard node from the banner's Remove `AlertManager` holds a single alert, so requesting one confirmation per node left only the last node's dialog and removed just that node. The banner now asks once for the whole list. --- .skills/compose-ui/strings-index.txt | 1 + .../commonMain/composeResources/values/strings.xml | 4 ++++ .../feature/node/detail/NodeManagementActions.kt | 14 ++++++++++++++ .../meshtastic/feature/node/list/NodeListScreen.kt | 6 +++--- .../feature/node/list/NodeListViewModel.kt | 2 +- .../node/detail/NodeManagementActionsTest.kt | 14 ++++++++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index ed13dd5a814..bf49d7b2733 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1575,6 +1575,7 @@ remove_filter remove_ignored remove_layer remove_node_text +remove_nodes_text replace replace_channels_and_settings_description reply diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index e0cb031919b..f13aa213829 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1635,6 +1635,10 @@ Remove from ignored Remove Layer This node will be removed from your list until your node receives data from it again. + + %1$d node will be removed from your list until your node receives data from it again. + %1$d nodes will be removed from your list until your node receives data from them again. + Replace This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed. Reply diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 991c416c0c0..d7a27aab4ee 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -16,11 +16,13 @@ */ package org.meshtastic.feature.node.detail +import androidx.compose.material3.Text import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.pluralStringResource import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.safeCatching @@ -43,6 +45,7 @@ import org.meshtastic.core.resources.mute_notifications import org.meshtastic.core.resources.mute_remove import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_node_text +import org.meshtastic.core.resources.remove_nodes_text import org.meshtastic.core.resources.unmute import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.SnackbarManager @@ -67,6 +70,17 @@ constructor( ) } + /** One alert for the batch: [AlertManager] holds a single alert, so per-node requests replace each other. */ + open fun requestRemoveNodes(scope: CoroutineScope, nodes: List) { + if (nodes.isEmpty()) return + if (nodes.size == 1) return requestRemoveNode(scope, nodes.single()) + alertManager.showAlert( + titleRes = Res.string.remove, + composableMessage = { Text(pluralStringResource(Res.plurals.remove_nodes_text, nodes.size, nodes.size)) }, + onConfirm = { launchRadioMutation(scope, "removeNodes") { nodes.forEach { removeNode(it.num) } } }, + ) + } + open suspend fun removeNode(nodeNum: Int) { Logger.i { "Removing node '$nodeNum'" } val packetId = radioController.generatePacketId() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index e80f378c9a0..b6cd2b0f3f3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -272,7 +272,7 @@ fun NodeListScreen( onTraceRoute = { viewModel.traceRoute(node) }, lastTracerouteTime = lastTracerouteTime, onIgnore = { viewModel.ignoreNode(node) }, - onRemove = { viewModel.removeNode(node) }, + onRemove = { viewModel.removeNodes(listOf(node)) }, onDismiss = { expanded = false }, ) } @@ -347,7 +347,7 @@ fun NodeListScreen( UnheardNodesBanner( unheardNodes = unheardNodes, onKeep = { viewModel.keepUnheardNodes(unheardNodes) }, - onRemoveAll = { unheardNodes.forEach(viewModel::removeNode) }, + onRemoveAll = { viewModel.removeNodes(unheardNodes) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), ) val filterPrefs = viewModel.nodeFilterPreferences @@ -462,7 +462,7 @@ private fun NodeListEmptyState( * node may simply be out of range rather than on another channel — it comes back on its own when next heard. * * Keep is saved per node, so the banner stays away across launches and returns only for nodes not yet kept. Remove - * hides it for this composition, since each removal still waits on its own confirmation. + * hides it for this composition, since the removal still waits on its confirmation. */ @Composable private fun UnheardNodesBanner( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index b52196ea46e..b6201b5e967 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -245,7 +245,7 @@ class NodeListViewModel( fun muteNode(node: Node) = nodeManagementActions.requestMuteNode(viewModelScope, node) - fun removeNode(node: Node) = nodeManagementActions.requestRemoveNode(viewModelScope, node) + fun removeNodes(nodes: List) = nodeManagementActions.requestRemoveNodes(viewModelScope, nodes) /** Returns the contact key for navigating to a direct message conversation with this node. */ fun getDirectMessageRoute(node: Node): String { diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 08296d4415d..6c7ba5dcf3e 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -107,6 +107,20 @@ class NodeManagementActionsTest { assertTrue(afterRemoveCalled) } + @Test + fun requestRemoveNodes_removes_every_node_after_one_confirmation() { + val realAlertManager = AlertManager() + val actionsWithRealAlert = actionsWith(radioController, realAlertManager) + val nodes = listOf(1, 2, 3).map { Node(num = it) } + nodeRepository.setNodes(nodes + Node(num = 4)) + + actionsWithRealAlert.requestRemoveNodes(testScope, nodes) + realAlertManager.currentAlert.value?.onConfirm?.invoke() + testScope.runCurrent() + + assertEquals(setOf(4), nodeRepository.nodeDBbyNum.value.keys) + } + @Test fun requestRemoveNode_success_callback_failure_is_not_reported_as_radio_rejection() { val realAlertManager = AlertManager()