diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index ed13dd5a81..bf49d7b273 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 e0cb031919..f13aa21382 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 991c416c0c..d7a27aab4e 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 e80f378c9a..b6cd2b0f3f 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 b52196ea46..b6201b5e96 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 08296d4415..6c7ba5dcf3 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()