mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 04:15:37 -04:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
3
.skills/compose-ui/strings-index.txt
generated
3
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="a11y_node_online">online</string>
|
||||
<string name="a11y_node_role">role %1$s</string>
|
||||
<string name="a11y_node_signal">signal %1$s</string>
|
||||
<string name="a11y_nodes_at_hop">Hop %1$d: %2$d nodes</string>
|
||||
<string name="accept">Accept</string>
|
||||
<string name="acknowledgements">Acknowledgements</string>
|
||||
<!-- ACTION -->
|
||||
@@ -737,6 +738,8 @@
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="history_return_max">History return max</string>
|
||||
<string name="history_return_window">History return window</string>
|
||||
<string name="hop_histogram_empty">No nodes heard in this window</string>
|
||||
<string name="hop_histogram_title">Nodes per Hop</string>
|
||||
<string name="hop_limit">Number of Hops</string>
|
||||
<string name="hops_away">Hops Away</string>
|
||||
<string name="host">Host</string>
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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<Node>, cutoffSecs: Int?): List<Int> {
|
||||
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<Node>, 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<Node>, 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),
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<Node> = 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 = {}) } }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Reference in New Issue
Block a user