Files

4.5 KiB

Data Model — Node List Layout

Overview

The Node List Layout feature does not introduce new database entities. It adds preference keys to DataStore and a density enum that controls how existing Node model data is rendered. The data model is entirely read-only from the layout perspective — node data comes from the existing Room KMP pipeline.

Entity Relationship

erDiagram
    DataStore ||--o{ NodeListLayoutPreferences : stores
    NodeListLayoutPreferences ||--|| NodeListDensity : selects
    NodeListDensity ||--|| NodeItem : "renders (COMPLETE)"
    NodeListDensity ||--|| NodeItemCompact : "renders (COMPACT)"
    NodeItemCompact ||--o{ CompactToggle : "visibility driven by"
    NodeItem }o--|| Node : reads
    NodeItemCompact }o--|| Node : reads
    Node ||--|| NodeEntity : "backed by"

Density Enum

package org.meshtastic.feature.node.model

enum class NodeListDensity {
    COMPLETE,
    COMPACT;
}
  • Persisted as a String (enum name) in DataStore under key nodeListDensity.
  • Default: COMPLETE.

Preference Keys

package org.meshtastic.core.prefs.ui

enum class NodeListLayoutPreferences(val key: String, val defaultValue: Boolean) {
    SHOW_POWER("shouldShowPower", true),
    SHOW_LAST_HEARD("shouldShowLastHeard", true),
    LAST_HEARD_RELATIVE("lastHeardIsRelative", false),
    SHOW_LOCATION("shouldShowLocation", true),
    SHOW_HOPS("shouldShowHops", true),
    SHOW_SIGNAL("shouldShowSignal", true),
    SHOW_CHANNEL("shouldShowChannel", true),
    SHOW_ROLE("shouldShowRole", true),
    SHOW_TELEMETRY("shouldShowTelemetry", true);
}

Preference Access Pattern

Each key is exposed as a StateFlow<Boolean> in UiPrefsImpl:

val shouldShowPower: StateFlow<Boolean> = dataStore.data
    .map { it[booleanPreferencesKey("shouldShowPower")] ?: true }
    .stateIn(scope, SharingStarted.Eagerly, true)

The density preference follows the same pattern but maps to the enum:

val nodeListDensity: StateFlow<NodeListDensity> = dataStore.data
    .map { prefs ->
        val name = prefs[stringPreferencesKey("nodeListDensity")] ?: "COMPLETE"
        NodeListDensity.valueOf(name)
    }
    .stateIn(scope, SharingStarted.Eagerly, NodeListDensity.COMPLETE)

Node Data Fields Used by Layout

The layout reads from the existing Node model in core:model. No new fields are added.

Field Type Used By Condition
longName String? Both Always shown
shortName String? Both NodeChip avatar
lastHeard Long Both Non-zero, not > 1 year future
hopsAway Int Both > 0 for hop count, == 0 for signal
snr Float Both != 0 and !viaMqtt
rssi Int Both Signal quality via determineSignalQuality(snr, rssi)
batteryLevel Int? Both Non-null
channel Int Both > 0
position Position? Both Non-null, valid lat/lon
role DeviceRole Both Always (defaults to 0)
viaMqtt Boolean Both Signal exclusion gate
isFavorite Boolean Both Star icon
hasPositionLog Boolean Both Log icon visibility
hasEnvironmentLog Boolean Both Log icon visibility
hasDetectionSensorLog Boolean Both Log icon visibility
hasTracerouteLog Boolean Both Log icon visibility
hasDeviceMetricsLog Boolean Both Log icon visibility

Adaptive Chip Sizing

The compact NodeChip size is derived from a lineCount property:

val lineCount: Int = buildList {
    add(1) // Row 1: name — always present
    if (shouldShowLastHeard) add(1)
    if (shouldShowLocation || shouldShowHops || shouldShowSignal ||
        shouldShowChannel || shouldShowRole || shouldShowTelemetry) add(1)
}.size

val chipSize: Dp = max(36.dp, min(70.dp, 24.dp * lineCount))
lineCount Chip Size Active Rows
1 36.dp Name only
2 48.dp Name + last heard OR Name + combined
3 70.dp Name + last heard + combined

Validation Rules

  • nodeListDensity must be a valid NodeListDensity enum name. Invalid values fall back to COMPLETE.
  • lastHeardIsRelative is functionally irrelevant when shouldShowLastHeard is false (the UI disables the toggle).
  • All boolean preferences default to true except lastHeardIsRelative which defaults to false.
  • The layout never writes to Node data — all mutations flow through the existing packet processing pipeline.