diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index b9a8d3e5a..58df5e50d 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -10,6 +10,7 @@ a11y_node_offline a11y_node_online a11y_node_role a11y_node_signal +a11y_nodes_at_hop accept acknowledgements ### ACTION ### @@ -713,6 +714,8 @@ hide_layer hide_password history_return_max history_return_window +hop_histogram_empty +hop_histogram_title hop_limit hops_away host diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9b97e57fb..11db13fbc 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -28,6 +28,7 @@ online role %1$s signal %1$s + Hop %1$d: %2$d nodes Accept Acknowledgements @@ -737,6 +738,8 @@ Hide password History return max History return window + No nodes heard in this window + Nodes per Hop Number of Hops Hops Away Host diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt new file mode 100644 index 000000000..b7003fea0 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogram.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.feature.node.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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.nowInstant +import org.meshtastic.core.model.Node +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.a11y_nodes_at_hop +import org.meshtastic.core.resources.all_time +import org.meshtastic.core.resources.eight_hours +import org.meshtastic.core.resources.hop_histogram_empty +import org.meshtastic.core.resources.hop_histogram_title +import org.meshtastic.core.resources.one_hour +import org.meshtastic.core.resources.twenty_four_hours +import kotlin.time.Duration.Companion.hours + +/** + * Counts nodes at each hop distance, as a list indexed by hop (element 0 = direct nodes). The list is contiguous from + * hop 0 up to the furthest hop observed, so zero-count gaps in the middle are preserved. Nodes with an unknown hop + * (`hopsAway < 0`) are excluded. When [cutoffSecs] is non-null, only nodes with `lastHeard >= cutoffSecs` are counted. + * Returns an empty list when nothing qualifies. + */ +fun hopHistogram(nodes: List, cutoffSecs: Int?): List { + val counts = + nodes + .filter { it.hopsAway >= 0 && (cutoffSecs == null || it.lastHeard >= cutoffSecs) } + .groupingBy { it.hopsAway } + .eachCount() + val maxHop = counts.keys.maxOrNull() ?: return emptyList() + return (0..maxHop).map { counts[it] ?: 0 } +} + +/** + * Bottom sheet showing how many mesh nodes sit at each hop distance, filtered to a "last heard" [HopWindow]. Gives a + * quick sense of how busy and dispersed the local mesh is (see issue #5745). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NodeHopHistogramSheet(nodes: List, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var window by remember { mutableStateOf(HopWindow.EIGHT_HOURS) } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + HopHistogramContent(nodes = nodes, window = window, onSelectWindow = { window = it }) + } +} + +/** Stateless sheet content — split out from [NodeHopHistogramSheet] so it can be previewed without a sheet host. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun HopHistogramContent(nodes: List, window: HopWindow, onSelectWindow: (HopWindow) -> Unit) { + val cutoffSecs = + remember(window) { window.hours?.let { (nowInstant.epochSeconds - it.hours.inWholeSeconds).toInt() } } + val buckets = remember(nodes, cutoffSecs) { hopHistogram(nodes, cutoffSecs) } + val maxCount = buckets.maxOrNull() ?: 0 + + Column( + modifier = + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.hop_histogram_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) + + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HopWindow.entries.forEach { option -> + FilterChip( + selected = option == window, + onClick = { onSelectWindow(option) }, + label = { Text(stringResource(option.label)) }, + ) + } + } + + if (buckets.isEmpty()) { + Text( + text = stringResource(Res.string.hop_histogram_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + buckets.forEachIndexed { hop, count -> HopBar(hop = hop, count = count, maxCount = maxCount) } + } + } +} + +@Composable +private fun HopBar(hop: Int, count: Int, maxCount: Int) { + // Merge the row into one spoken node so a screen reader announces "Hop 3: 2 nodes" rather than + // reading the hop number, progress-bar percentage, and count as three disjoint elements. + val description = stringResource(Res.string.a11y_nodes_at_hop, hop, count) + Row( + modifier = Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = hop.toString(), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(min = 20.dp), + ) + LinearProgressIndicator( + progress = { if (maxCount > 0) count.toFloat() / maxCount else 0f }, + modifier = Modifier.weight(1f), + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + Text( + text = count.toString(), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(min = 28.dp), + ) + } +} + +/** "Last heard" windows offered above the histogram. A `null` [hours] means no time filter (all known nodes). */ +@Suppress("MagicNumber") +enum class HopWindow(val hours: Long?, val label: StringResource) { + ALL(null, Res.string.all_time), + ONE_HOUR(1, Res.string.one_hour), + EIGHT_HOURS(8, Res.string.eight_hours), + ONE_DAY(24, Res.string.twenty_four_hours), +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt new file mode 100644 index 000000000..a928fff7d --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramPreviews.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 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 . + */ +@file:Suppress("MagicNumber", "PreviewPublic") + +package org.meshtastic.feature.node.component + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.model.Node +import org.meshtastic.core.ui.theme.AppTheme + +// A spread of hop distances with hop 4 intentionally missing, to show the preserved zero-count gap. +// Built inside a function (not an eager top-level val) so it can't poison this file's class initializer. +private fun sampleNodes(): List = buildList { + listOf(0 to 8, 1 to 12, 2 to 5, 3 to 2, 5 to 3).forEach { (hop, count) -> + repeat(count) { i -> add(Node(num = hop * 1000 + i, hopsAway = hop)) } + } +} + +@PreviewLightDark +@Composable +fun HopHistogramContentPreview() { + val nodes = remember { sampleNodes() } + AppTheme { Surface { HopHistogramContent(nodes = nodes, window = HopWindow.ALL, onSelectWindow = {}) } } +} + +@PreviewLightDark +@Composable +fun HopHistogramEmptyPreview() { + AppTheme { Surface { HopHistogramContent(nodes = emptyList(), window = HopWindow.ALL, onSelectWindow = {}) } } +} 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 0e203cfc9..8397df596 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 @@ -62,6 +62,7 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.NodeListDensity import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_invalid +import org.meshtastic.core.resources.hop_histogram_title import org.meshtastic.core.resources.node_count_template import org.meshtastic.core.resources.node_list_help_title import org.meshtastic.core.resources.nodes @@ -77,6 +78,7 @@ import org.meshtastic.core.ui.component.NodeItemCompact import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.SharedContactDialog import org.meshtastic.core.ui.component.smartScrollToTop +import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NoDevice @@ -84,6 +86,7 @@ import org.meshtastic.core.ui.icon.Nodes import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid import org.meshtastic.feature.node.component.NodeContextMenu import org.meshtastic.feature.node.component.NodeFilterTextField +import org.meshtastic.feature.node.component.NodeHopHistogramSheet import org.meshtastic.feature.node.component.NodeListHelp @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -145,6 +148,11 @@ fun NodeListScreen( NodeListHelp(onDismiss = { showHelpSheet = false }) } + var showHopHistogram by remember { mutableStateOf(false) } + if (showHopHistogram) { + NodeHopHistogramSheet(nodes = unfilteredNodes, onDismiss = { showHopHistogram = false }) + } + var showShareContact by remember { mutableStateOf(false) } if (showShareContact) { SharedContactDialog(contact = ourNode, onDismiss = { showShareContact = false }) @@ -161,6 +169,12 @@ fun NodeListScreen( canNavigateUp = false, onNavigateUp = {}, actions = { + IconButton(onClick = { showHopHistogram = true }) { + Icon( + imageVector = MeshtasticIcons.BarChart, + contentDescription = stringResource(Res.string.hop_histogram_title), + ) + } IconButton(onClick = { showHelpSheet = true }) { Icon( imageVector = MeshtasticIcons.Info, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt new file mode 100644 index 000000000..61398eb26 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/NodeHopHistogramTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.feature.node.component + +import org.meshtastic.core.model.Node +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Tests for [hopHistogram] — the pure bucketing behind the nodes-per-hop view. */ +class NodeHopHistogramTest { + + private fun node(hops: Int, lastHeard: Int = 1_000) = + Node(num = hops * 100 + lastHeard, hopsAway = hops, lastHeard = lastHeard) + + @Test + fun empty_input_gives_empty_list() { + assertEquals(emptyList(), hopHistogram(emptyList(), cutoffSecs = null)) + } + + @Test + fun buckets_are_contiguous_and_preserve_middle_gaps() { + val nodes = listOf(node(0), node(0), node(1), node(3)) + // hop 2 has zero nodes but must still appear between 1 and 3 + assertEquals(listOf(2, 1, 0, 1), hopHistogram(nodes, cutoffSecs = null)) + } + + @Test + fun unknown_hops_are_excluded() { + val nodes = listOf(node(-1), node(0), node(-1)) + assertEquals(listOf(1), hopHistogram(nodes, cutoffSecs = null)) + } + + @Test + fun all_unknown_gives_empty_list() { + assertEquals(emptyList(), hopHistogram(listOf(node(-1), node(-1)), cutoffSecs = null)) + } + + @Test + fun cutoff_drops_nodes_heard_before_it() { + val nodes = listOf(node(0, lastHeard = 100), node(0, lastHeard = 500), node(1, lastHeard = 500)) + // cutoff=300 keeps only lastHeard >= 300 + assertEquals(listOf(1, 1), hopHistogram(nodes, cutoffSecs = 300)) + } + + @Test + fun null_cutoff_keeps_everything_regardless_of_last_heard() { + val nodes = listOf(node(0, lastHeard = 0), node(0, lastHeard = 999)) + assertEquals(listOf(2), hopHistogram(nodes, cutoffSecs = null)) + } +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt index 99b03c6e8..ce5977a87 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.tools.screenshot.PreviewTest import org.meshtastic.feature.node.component.DeviceActionsLocalPreview import org.meshtastic.feature.node.component.DeviceActionsRemotePreview +import org.meshtastic.feature.node.component.HopHistogramContentPreview +import org.meshtastic.feature.node.component.HopHistogramEmptyPreview import org.meshtastic.feature.node.component.NodeDetailsSectionPreview import org.meshtastic.feature.node.component.NodeDetailsSectionSignedPreview import org.meshtastic.feature.node.component.NodeItemCompactActivePreview @@ -215,3 +217,21 @@ fun ScreenshotNodeItemCompleteOnlineRemote() { fun ScreenshotNodeDetailContentMinimal() { NodeDetailContentMinimalPreview() } + +// --------------------------------------------------------------------------- +// Nodes-per-hop histogram (issue #5745) +// --------------------------------------------------------------------------- + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotHopHistogramContent() { + HopHistogramContentPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotHopHistogramEmpty() { + HopHistogramEmptyPreview() +} diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..98dcc445a Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png new file mode 100644 index 000000000..9f2d855e9 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramContent_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..91a7c876d Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png new file mode 100644 index 000000000..795a9ca24 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotHopHistogramEmpty_Light_b29dc7a7_0.png differ