Files
Meshtastic-Android/specs/002-node-list-layout/spec.md

23 KiB
Raw Blame History

Feature Specification: Node List Layout

Feature Branch: 002-node-list-layout Created: 2026-05-07 Status: Not Started Input: Node layout engine with complete and compact views for Compose Multiplatform

Summary

Node List Layout introduces a density-switching system for the Meshtastic node list, giving users a choice between a full-detail "Complete" layout and a condensed "Compact" layout with per-field toggle controls. The feature lives entirely in commonMain (Compose Multiplatform) and persists preferences via DataStore.

Goals

  1. Let users reduce visual noise and scrolling on large meshes (100+ nodes) by switching to a compact row layout.
  2. Give users fine-grained control over which data fields appear in compact mode via individually toggleable switches.
  3. Provide adaptive chip sizing so the compact layout scales gracefully as fields are enabled or disabled.
  4. Document signal strength color semantics in a discoverable help sheet.

Non-Goals

  • Modifying the Complete layout to support per-field toggles (it intentionally shows everything).
  • Adding new data fields or metrics beyond what the node model already provides.
  • Platform-specific layout variations — all UI is shared commonMain.

User Scenarios & Testing (mandatory)

User Story 1 — Switch Between Complete and Compact Density (Priority: P1)

A Meshtastic user with a large mesh (100+ nodes) wants a denser node list to reduce scrolling. They navigate to Settings > App Settings > Node Layout, switch from "Complete" to "Compact" using a segmented button, and the node list immediately re-renders with smaller rows. Switching back to "Complete" restores the full-detail layout.

Why this priority: The density switch is the core mechanic — all compact-specific toggles depend on it existing.

Independent Test: Open Settings > Node Layout, toggle between Complete and Compact, navigate to the Nodes tab, and verify the list renders with the correct row style.

Acceptance Scenarios:

  1. Given the user has "Complete" selected, When they view the node list, Then each row displays the full-detail NodeItem layout with all available data fields.
  2. Given the user switches to "Compact," When the node list re-renders, Then each row uses NodeItemCompact with a condensed two-column layout.
  3. Given the user switches density, When the segmented button animates, Then the transition completes within 300ms at 60fps and a live preview node in Settings updates immediately.
  4. Given the app is relaunched, When the node list loads, Then the previously selected density is restored from DataStore preferences.

User Story 2 — Configure Compact Layout Fields (Priority: P1)

A user in Compact mode wants to hide fields they don't care about (e.g., telemetry log icons) and keep only what matters (e.g., last heard time and signal). They open Settings > Node Layout, see toggles for each compact field ordered to match the visual layout, and flip individual switches. The live preview node at the bottom of the settings section updates in real time.

Why this priority: Per-field toggles are the primary value proposition of the compact layout — without them, compact is just a fixed alternative.

Independent Test: Switch to Compact, disable all toggles one by one, verify each field disappears from both the preview and the node list. Re-enable them and verify they reappear.

Acceptance Scenarios:

  1. Given the user is in Compact mode, When they toggle "Power" off, Then the battery indicator below the chip disappears.
  2. Given "Last Heard Time" is toggled off, When the node list renders, Then the last-heard row (online/offline icon + timestamp) is hidden and the chip shrinks.
  3. Given "Distance and Bearing" is toggled off, When a node has position data, Then the distance text and compass arrow are hidden from the combined row.
  4. Given "Hops Away" is toggled off, When a node has hopsAway > 0, Then the hop count indicator is hidden.
  5. Given "Signal (Direct Only)" is toggled off, When a direct node has SNR data, Then the color-coded signal indicator is hidden.
  6. Given "Channel" is toggled off, When a node is on channel > 0, Then the channel number indicator is hidden.
  7. Given "Device Role" is toggled off, When the node list renders, Then the role icon, unmessagable icon, store-and-forward icon, and MQTT icon are all hidden.
  8. Given "Log Icons" is toggled off, When a node has telemetry logs, Then the device metrics, positions, environment, sensor, and trace route icons are hidden.
  9. Given "Relative Last Heard Time" is toggled on and "Last Heard Time" is on, When the row renders, Then the timestamp shows relative format (e.g., "2 hours ago") instead of absolute date/time.
  10. Given "Last Heard Time" is toggled off, When the user views the "Relative Last Heard Time" toggle, Then it is disabled (grayed out).

User Story 3 — Adaptive Chip Sizing (Priority: P2)

As the user disables rows in the compact view, the short-name chip should shrink proportionally so that single-row nodes don't have an oversized avatar. The chip always renders as a NodeChip composable — maintaining its card shape at all sizes.

Why this priority: Visual consistency and density optimization. Without adaptive sizing, disabling rows wastes vertical space.

Independent Test: Disable all optional rows (last heard + combined row), verify the chip shrinks to minimum size (36.dp). Enable all rows, verify it grows to maximum (70.dp).

Acceptance Scenarios:

  1. Given all toggleable rows are enabled (lineCount == 3), When the compact row renders, Then the chip height is 70.dp.
  2. Given only the name row is active (lineCount == 1), When the compact row renders, Then the chip height is 36.dp (minimum).
  3. Given any toggle configuration, When the compact row renders, Then the short name always displays as a NodeChip, maintaining consistent card styling.

User Story 4 — Signal Strength Help Documentation (Priority: P3)

A user sees colored signal indicators on their node list and wants to understand what the colors mean. They tap the help button (?) at the bottom of the node list and see a documented legend with color-coded signal entries and a description of the signal quality indicator.

Why this priority: Discoverability — without documentation the color scheme is opaque to new users.

Independent Test: Open the node list, tap the help icon, scroll to the "Node Details" section, verify all four signal quality entries (Good/Fair/Bad/None) and the signal quality indicator entry are present with correct colors and descriptions.

Acceptance Scenarios:

  1. Given the user opens Node List Help, When they scroll to "Node Details," Then they see four signal quality entries with green, yellow, orange, and red signal icons from the Quality enum drawables.
  2. Given the user reads "Signal: Good," Then the subtitle explains SNR is above 7 dB and RSSI is above 115 dBm.
  3. Given the user reads "Signal Quality Indicator," Then the entry shows the LoraSignalIndicator composable and explains it combines SNR and RSSI into a quality level (Complete layout only).

Edge Cases

  • All compact toggles disabled: Only the name row (long name, key status icon, favorite star) and the chip remain. The chip shrinks to 36.dp. Battery is hidden.
  • Node has no data for a toggled-on field: The field is simply absent — no placeholder or empty state. For example, "Distance and Bearing" enabled but node has no position → nothing shown.
  • Signal vs. Hops mutual exclusivity: Signal (Direct Only) only renders when hopsAway == 0 (direct connection). Hops Away only renders when hopsAway > 0. They never appear simultaneously for the same node.
  • Channel 0: Channel indicator is hidden when channel == 0 regardless of toggle state, since channel 0 is the default primary channel.
  • Connected node excluded from distance: Distance and bearing are never shown for the directly connected node (connectedNode == node.num).
  • MQTT nodes excluded from signal: Signal strength is hidden for nodes heard via MQTT (viaMqtt == true), since SNR/RSSI is not meaningful for internet-relayed packets.
  • Future date filtering: Last heard time is hidden if the timestamp is more than 1 year in the future (guards against clock-skew or corrupted data).

Architecture

Layout Structure — Compact

┌──────────────────────────────────────────────────────┐
│ ┌──────────┐  Row 1: 🔑 Long Name          ★ (fav)  │
│ │ NodeChip  │  Row 2: ● Last Heard Time              │
│ │  (Short)  │  Row 3: [dist] [hops|signal] [ch]      │
│ │  Battery  │         [role] [telemetry icons]        │
│ └──────────┘                                          │
└──────────────────────────────────────────────────────┘
  • Column 1 (fixed width): NodeChip composable (adaptive size) + optional MaterialBatteryInfo
  • Column 2 (weight 1f): Column(verticalArrangement = spacedBy(2.dp)) with up to 3 rows
    • Row 1 (always visible): NodeKeyStatusIcon, long name, favorite star
    • Row 2 (toggle: Last Heard Time): Online/offline icon + timestamp via LastHeardInfo
    • Row 3 (toggle: any of Distance/Hops/Signal/Channel/Role/Telemetry): Combined Row of chips separated by VerticalDivider(height = 15.dp)

Layout Structure — Complete

┌──────────────────────────────────────────────────────┐
│ ┌──────────┐  Row 1: 🔑 Long Name          ★ (fav)  │
│ │ NodeChip  │  Row 2: 📡 Connected (if direct)       │
│ │  (Short)  │  Row 3: ● Last Heard (always shown)    │
│ │  Battery  │  Row 4: 👤 Role: <name>                │
│ └──────────┘  Row 5: 📏 Distance + Bearing           │
│               Row 6: Channel + MQTT                   │
│               Row 7: 📜 Logs: [icons]                 │
│               Row 8: 🐇 Hops Away OR Signal Quality   │
└──────────────────────────────────────────────────────┘
  • No user-configurable toggles — all fields shown when data exists
  • Signal shown as LoraSignalIndicator / NodeSignalQuality composable (icon + SNR/RSSI text with quality color)
  • Chip is fixed at 70.dp

Data Flow

flowchart TD
    A["DataStore: nodeListDensity"] --> B{Density?}
    B -->|Complete| C[NodeItem]
    B -->|Compact| D[NodeItemCompact]
    D --> E["DataStore: compact toggles (9 keys)"]
    E --> F[lineCount computed property]
    F --> G["Chip size: max(36.dp, min(70.dp, 24.dp × lines))"]
    E --> H[Conditional field rendering]
    H --> I["Row 1: Name — always"]
    H --> J["Row 2: Last Heard — toggle"]
    H --> K["Row 3: Combined icons — toggles"]
    
    L[NodeLayoutSettings] --> M["Toggle UI — ordered by layout position"]
    M --> E
    M --> N[Live preview node]

Key Components

Component Module / File Purpose
NodeListDensity feature/node/model/ Enum: COMPLETE / COMPACT
NodeListLayoutPreferences core/prefs/ui/ DataStore keys for density + 9 compact toggles
NodeItemCompact feature/node/component/ Compact row composable with toggle-driven visibility
NodeItem feature/node/component/ Complete row composable — no toggles, all data shown
NodeLayoutSettings feature/settings/ Density picker (SegmentedButton) + compact toggles + live preview
NodeListScreen feature/node/list/ Parent composable — reads density, delegates to correct item
NodeListHelp feature/node/component/ Help bottom sheet with signal legend + quality indicator docs
NodeChip core/ui/component/NodeChip.kt Reusable short-name avatar chip (Card-based)
MaterialBatteryInfo core/ui/component/ Battery level indicator
HopsInfo core/ui/component/ Hop count chip
DistanceInfo core/ui/component/ Distance + bearing chip
Snr / Rssi core/ui/component/LoraSignalIndicator.kt Signal quality value displays
LastHeardInfo core/ui/component/ Last heard timestamp chip
LoraSignalIndicator core/ui/component/LoraSignalIndicator.kt Signal quality icon + description (Complete mode)
NodeSignalQuality core/ui/component/LoraSignalIndicator.kt SNR + RSSI + quality in FlowRow (Complete mode)
NodeKeyStatusIcon core/ui/component/NodeKeyStatusIcon.kt PKC/key status icon
NodeStatusIcons feature/node/component/NodeStatusIcons.kt Favorite, muted, unmessageable, connection status icons

Requirements (mandatory)

Functional Requirements

  • FR-001: The system MUST provide a "Node Layout" section in App Settings with a SegmentedButton offering "Complete" and "Compact" density options.
  • FR-002: The selected density MUST persist across app launches via DataStore using the nodeListDensity preference key in core:prefs.
  • FR-003: When "Compact" is selected, the system MUST display 9 toggles using SwitchPreference (from core:ui) in the order they appear in the compact layout: Power, Last Heard Time, Relative Last Heard Time, Distance and Bearing, Hops Away, Signal (Direct Only), Channel, Device Role, Log Icons.
  • FR-004: Each toggle MUST persist its state via DataStore using the corresponding NodeListLayoutPreferences key.
  • FR-005: All toggles MUST default to true (enabled) except "Relative Last Heard Time" which defaults to false.
  • FR-006: The "Relative Last Heard Time" toggle MUST be disabled (grayed out via enabled = false) when "Last Heard Time" is toggled off.
  • FR-007: When "Complete" is selected, the toggle section MUST be replaced with descriptive text: "The Complete layout displays all available node data. Fields with no data are automatically hidden."
  • FR-008: A live preview MUST render below the toggles using a representative node from Room KMP, reflecting the current density and toggle state in real time via collectAsState().
  • FR-009: The compact layout MUST render as a two-column Row: Column 1 (fixed width: NodeChip + battery), Column 2 (Modifier.weight(1f): Column of up to 3 content rows).
  • FR-010: The short name MUST always render as a NodeChip composable in compact mode, maintaining consistent card styling at all sizes.
  • FR-011: The chip height MUST scale adaptively: max(36.dp, min(70.dp, 24.dp × lineCount)) where lineCount counts active row groups (1 base + 1 if last-heard enabled + 1 if any combined-row toggle is enabled). The chip MUST use Modifier.defaultMinSize() rather than hard Modifier.size() to allow growth when system font scaling exceeds 100%.
  • FR-012: Row 1 (name) MUST always display: NodeKeyStatusIcon (PKC/key status icon from MeshtasticIcons), long name, and favorite star (if favorited). This row is not toggleable.
  • FR-013: Row 2 (last heard) MUST display when the "Last Heard Time" toggle is on and the node has a valid lastHeard timestamp (non-zero, not more than 1 year in the future). It shows an online (green checkmark) or offline (orange moon) icon plus the formatted timestamp via LastHeardInfo.
  • FR-014: Row 3 (combined icons) MUST display as a Row(horizontalArrangement = spacedBy(6.dp), modifier = Modifier.height(IntrinsicSize.Min)) with VerticalDivider(modifier = Modifier.fillMaxHeight()) between groups, in this order: Distance+Bearing, Hops Away, Signal, Channel, Device Role, Log Icons.
  • FR-015: Distance and Bearing MUST only render when the toggle is on, the node has positions, the node is not the connected node, and valid location data is available for both the user and the node.
  • FR-016: Hops Away MUST only render when the toggle is on and node.hopsAway > 0.
  • FR-017: Signal MUST only render when the toggle is on, node.hopsAway == 0, node.snr != 0, and node.viaMqtt == false. The icon color MUST use the Quality enum from determineSignalQuality(snr, rssi). The signal icon MUST include contentDescription = stringResource(quality.nameRes) (e.g., "Signal: Good") to satisfy WCAG 1.4.1 (no color-only information).
  • FR-018: Channel MUST only render when the toggle is on and node.channel > 0 (non-default channel).
  • FR-019: Device Role MUST render the role's MeshtasticIcons icon plus conditional unmessagable, store-and-forward, and MQTT icons when the toggle is on.
  • FR-020: Log Icons MUST render when the toggle is on and the node has at least one of: positions, environment metrics, detection sensor metrics, or trace routes. Icons shown: device metrics, positions (mappin), environment, detection sensor, trace routes (signpost) — all from MeshtasticIcons.
  • FR-021: The complete layout (NodeItem) MUST display all data fields unconditionally (no user toggles), hiding fields only when the underlying data is absent.
  • FR-022: The complete layout MUST show signal strength via LoraSignalIndicator / NodeSignalQuality composables (quality icon + SNR/RSSI text with quality color), not as a single colored icon.
  • FR-023: The Node List Help sheet MUST document signal quality levels (Good/green, Fair/yellow, Bad/orange, None/red) with the appropriate MeshtasticIcons signal icon for each.
  • FR-024: The Node List Help sheet MUST document the LoraSignalIndicator composable used in the Complete layout, explaining how it combines SNR and RSSI into a quality level.
  • FR-025: Both compact and complete layouts MUST include full TalkBack accessibility:
    • The outer Card MUST use Modifier.semantics(mergeDescendants = true) with a composed contentDescription that aggregates: name, connection status, favorite status, last heard, online/offline, role, hops, battery, distance, heading, and signal strength.
    • Clickable rows MUST declare role = Role.Button in their semantics block so TalkBack announces "double tap to activate."
    • The node name in compact mode MUST use titleMediumEmphasized (M3 Expressive) to match the complete layout typography.
  • FR-026: Compact rows MUST use Column(verticalArrangement = spacedBy(2.dp)) for consistent tight inter-row spacing. (Intentional deviation from M3's 4-8dp guideline — compact mode explicitly trades spacing for density.)
  • FR-027: Compact rows MUST have 2.dp top and bottom padding. Complete rows MUST have 3.dp top and bottom padding. (Intentional deviation from M3's 8-16dp list item padding — complete mode uses the existing 12.dp internal padding within the Card.)
  • FR-028: Any floating-point values displayed in the UI (e.g., distance, SNR) MUST be pre-formatted using NumberFormatter.format() before rendering, per CMP string formatting constraints.

Non-Functional Requirements

  • NFR-001: Toggle state changes MUST reflect in the UI within one recomposition cycle (no perceptible delay).
  • NFR-002: The compact layout MUST render smoothly at 60fps when scrolling a LazyColumn of 200+ nodes.
  • NFR-003: DataStore preference keys MUST use the NodeListLayoutPreferences enum values to prevent key string drift.
  • NFR-004: The compact view MUST use LazyColumn with stable key parameters for efficient rendering and item reuse.

Signal Quality Thresholds

Signal quality is determined by determineSignalQuality(snr, rssi) using absolute thresholds:

Condition Quality Color Help Label
SNR > 7 dB AND RSSI > 115 dBm GOOD Green Good
SNR > 12 dB AND RSSI > 120 dBm FAIR Yellow Fair
SNR > 18 dB AND RSSI > 125 dBm BAD Orange Bad
Below all thresholds NONE Red None

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: A user can switch between Complete and Compact density and see the node list re-render within 1 second.
  • SC-002: All 9 compact toggles persist across app launches and correctly show/hide their corresponding UI elements.
  • SC-003: The live preview in Settings accurately reflects the current toggle configuration for both density modes.
  • SC-004: The help sheet documents all signal quality indicators including the 4 color-coded icon entries and the LoraSignalIndicator entry.
  • SC-005: Compact mode reduces visible row height by at least 40% compared to Complete mode for a node with full data.
  • SC-006: TalkBack reads a complete, meaningful description for nodes in both Complete and Compact layouts.

Toggle Reference

Toggle Label DataStore Key Default Layout Position Data Condition
Power shouldShowPower true Column 1, below chip node.batteryLevel != null
Last Heard Time shouldShowLastHeard true Row 2 Valid lastHeard timestamp
Relative Last Heard Time lastHeardIsRelative false Row 2 (format only) shouldShowLastHeard == true
Distance and Bearing shouldShowLocation true Row 3, position 1 Node has positions + not connected node + valid location
Hops Away shouldShowHops true Row 3, position 2 hopsAway > 0
Signal (Direct Only) shouldShowSignal true Row 3, position 3 hopsAway == 0 + snr != 0 + !viaMqtt
Channel shouldShowChannel true Row 3, position 4 channel > 0
Device Role shouldShowRole true Row 3, position 5 Always (role defaults to 0)
Log Icons shouldShowTelemetry true Row 3, position 6 Node has positions/environment/sensor/traces

Assumptions

  • The node list data model (Node in core:model, backed by NodeEntity in core:database) is fully populated by the packet processing pipeline. The layout engine only reads — it never writes to the model.
  • NodeChip, MaterialBatteryInfo, HopsInfo, DistanceInfo, LoraSignalIndicator, and LastHeardInfo are pre-existing reusable components in core:ui. The layout engine composes them but does not modify them.
  • determineSignalQuality(snr, rssi) uses absolute SNR/RSSI thresholds (not modem-preset-relative). The Quality enum (GOOD, FAIR, BAD, NONE) drives icon color for both layouts.
  • The live preview uses the first node from a Room KMP query sorted by lastHeard descending — it requires at least one node in the database to render.
  • The Complete layout is intentionally not configurable — its purpose is to show everything, acting as the baseline reference.
  • All business logic and UI composables reside in commonMain source set. No platform-specific code is required for this feature.
  • String resources for toggle labels and help text are added to core/resources/src/commonMain/composeResources/values/strings.xml using stringResource(Res.string.key).
  • This feature does not log or expose PII, location data, or cryptographic keys. Node data is displayed read-only from the existing database — no new data collection or network calls are introduced.