diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 4015a3c5fff..ddb33ec1ce3 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1983,6 +1983,8 @@ website
weight
what_is_meshtastic
what_is_meshtastic_description
+widget_node_tracker_choose
+widget_node_tracker_not_found
### WIFI ###
wifi_config
wifi_devices
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 1fa0ade959f..c1ccd1d6410 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -354,6 +354,29 @@
android:resource="@xml/widget_local_stats_info" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Wi-Fi Options
Wi-Fi Provisioning for mPWRD-OS
diff --git a/docs/en/user/widget.md b/docs/en/user/widget.md
index 55fe654604f..7cca0b23fde 100644
--- a/docs/en/user/widget.md
+++ b/docs/en/user/widget.md
@@ -2,21 +2,27 @@
title: Home Screen Widget
parent: User Guide
nav_order: 20
-last_updated: 2026-08-30
-description: Add the Meshtastic home screen widget to glance at your connected radio's local stats without opening the app.
+last_updated: 2026-10-20
+description: Add Meshtastic home screen widgets to glance at your connected radio's local stats, or keep an eye on one node you pick, without opening the app.
aliases:
- widget
- home-screen-widget
- local-stats-widget
+ - node-tracker-widget
---
# Home Screen Widget
-On Android, Meshtastic provides a home screen **widget** that shows live local statistics from your connected radio at a glance — no need to open the app.
+On Android, Meshtastic provides two home screen **widgets**:
-## What It Shows
+- **Meshtastic** shows live local statistics from your connected radio.
+- **Node Tracker** follows one node you choose: its battery, signal, when it was last heard, how many hops away it is, and how far it is from your radio.
-The widget displays the **connected radio's** current local stats:
+You can place as many of each as you like. Each Node Tracker remembers its own node.
+
+## Local Stats
+
+The **Meshtastic** widget displays the **connected radio's** current local stats:
- A **node chip** across the top, carrying the radio's short name in its own colors
- **Battery** — the radio's battery level, or *Powered* when running on external power
@@ -34,17 +40,41 @@ Tap the widget to open the app, or use its refresh control to request fresh stat
> ℹ️ **Note:** The values reflect the connected radio. If the radio disconnects, the widget replaces the stats with a status line — **Disconnected**, **Connecting**, or **Device sleeping**. It does not keep the last-known numbers on screen.
-## Adding the Widget
+## Node Tracker
+
+The **Node Tracker** widget shows one node from your node list:
+
+- The node's **chip** and **long name**
+- **Battery**: the node's last reported battery level, or *Powered* on external power
+- **Last heard**: the time your radio last heard the node, or the date if it was more than a day ago
+
+Widen the widget to add a second column:
+
+- **SNR** and **RSSI**: the signal of the node's last packet, shown only when the node is heard directly rather than through other nodes or MQTT
+- **Hops Away**: how many hops away the node is, or *Direct*
+- **Distance**: how far the node is from your radio. It appears only while your radio is connected and both have a position
+
+When a node has not been heard within the last two hours, the widget dims its readings so they do not read as current. Tap the widget to open that node's details in the app.
+
+After you disconnect from your radio in the app, the widget keeps the node's last readings on screen, dimmed, until you reconnect.
+
+If your radio's node list does not include the node you picked, because you removed it or connected to a different radio, the widget says **Node not found**. Tap it to choose another node.
+
+## Adding a Widget
1. Touch & hold an empty area of your Android home screen.
-2. Tap **Widgets**.
-3. Drag the **Meshtastic** widget to your home screen. The app ships one widget, so the picker entry is just the app name.
-4. Resize it as needed — the layout adapts to the available space.
+2. Tap **Widgets**, then find **Meshtastic** in the list.
+3. Drag the **Meshtastic** (local stats) or **Node Tracker** widget to your home screen.
+4. For a Node Tracker, pick the node to follow. Your favorites are listed first, then the most recently heard nodes; type in the search field to filter by name or node ID. Backing out without picking cancels the widget.
+5. Resize it as needed — the layout adapts to the available space.
+
+To track a different node later, touch & hold the Node Tracker and choose the widget's settings or reconfigure option. The exact wording depends on your launcher.
-> ℹ️ **Note:** The widget is Android-only. It is not available on the Desktop or iOS builds.
+> ℹ️ **Note:** The widgets are Android-only. They are not available on the Desktop or iOS builds.
## Related Topics
- [Node Metrics](node-metrics) — the full Signal Quality and Local Stats history inside the app
-- [Connections](connections) — connect to a radio so the widget has stats to show
+- [Nodes](nodes) — favorites, last heard, and node details
+- [Connections](connections) — connect to a radio so the widgets have data to show
- [Local Mesh Discovery](discovery) — channel and airtime utilization across the mesh
diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt
index 30c6af270cc..8c7a78a4b76 100644
--- a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt
+++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt
@@ -447,7 +447,7 @@ class DefaultDocBundleLoader : DocBundleLoader {
CoreRes.string.doc_keywords_widget,
"en/user/widget.html",
20,
- listOf("widget", "home-screen-widget", "local-stats-widget"),
+ listOf("widget", "home-screen-widget", "local-stats-widget", "node-tracker-widget"),
1700,
"widget",
),
diff --git a/feature/widget/README.md b/feature/widget/README.md
index c704f52756e..378fe5753a7 100644
--- a/feature/widget/README.md
+++ b/feature/widget/README.md
@@ -2,15 +2,18 @@
## Overview
-The `:feature:widget` module is an **Android-only Jetpack Glance home-screen widget** (API 26+). It exposes a single widget — `LocalStatsWidget` — that displays live mesh statistics sourced from the running Meshtastic service.
+The `:feature:widget` module holds the **Android-only Jetpack Glance home-screen widgets** (API 26+):
+
+- `LocalStatsWidget` shows live mesh statistics for the connected radio.
+- `NodeTrackerWidget` follows one node the user picks: battery, signal, last heard, hops away and distance from our radio.
**Target: Android only** (not KMP — uses `meshtastic.android.library` + `meshtastic.android.library.compose` convention plugins)
## Key Responsibilities
-- Display live mesh statistics on the Android home screen via Jetpack Glance
+- Display live mesh statistics and a tracked node on the Android home screen via Jetpack Glance
- Combine four reactive data sources into a single `StateFlow` for efficient re-renders
-- Support three responsive widget sizes: small square, wide rectangle, and large square
+- Pick the tracked node in `NodeTrackerConfigActivity`, the widget's configuration activity, and store it per instance in Glance state
- Fulfil the `AppWidgetUpdater` interface from `:core:repository` so the mesh service can trigger widget refreshes without depending on Android widget APIs
## Source Structure
@@ -20,13 +23,32 @@ src/main/kotlin/org/meshtastic/feature/widget/
├── LocalStatsWidget.kt ← GlanceAppWidget, 3 responsive sizes
├── LocalStatsWidgetReceiver.kt ← GlanceAppWidgetReceiver
├── LocalStatsWidgetState.kt ← LocalStatsWidgetUiState + StateProvider
+├── NodeTrackerWidget.kt ← GlanceAppWidget, 2 responsive sizes
+├── NodeTrackerWidgetReceiver.kt ← GlanceAppWidgetReceiver
+├── NodeTrackerWidgetState.kt ← NodeTrackerWidgetState, nodeTrackerState(), DataSource
+├── NodeTrackerConfigActivity.kt ← APPWIDGET_CONFIGURE node picker (Compose, Material 3)
+├── NodeTrackerCandidates.kt ← picker filter and order
+├── WidgetNodeChip.kt ← node chip shared by both widgets
├── AndroidAppWidgetUpdater.kt ← implements AppWidgetUpdater (core:repository)
├── RefreshLocalStatsAction.kt ← GlanceActionCallback (refresh button)
└── di/
└── FeatureWidgetModule.kt
```
-## Widget Content
+## Node Tracker
+
+Each instance stores its node number under `TRACKED_NODE_NUM_KEY` in its Glance Preferences state, and the last reading it rendered from a node DB under the `last_known_*` keys, which it shows dimmed while no radio's node DB is open. `NodeTrackerConfigActivity` writes it, from the launcher's `APPWIDGET_CONFIGURE` on placement or reconfiguration (`widgetFeatures="reconfigurable"`), or from the widget's own "Node not found" state. The activity is exported for the launcher, so it honors only widget ids bound to `NodeTrackerWidgetReceiver`, and it returns `RESULT_CANCELED` unless a node is picked.
+
+| Size | Data shown |
+|---|---|
+| Small (under 200 dp wide) | Node chip, long name, battery, last heard |
+| Wide | Adds SNR and RSSI (direct, non-MQTT nodes only), hops away, distance (only while connected) |
+
+`nodeTrackerState()` maps the node DB to what one instance renders. A node not heard within the app's online window renders dimmed; the provider's 30-minute `updatePeriodMillis` re-renders a node that has gone quiet. Tapping opens the node's details through the `nodes/` deep link. The picker lists favorites first, then the most recently heard, leaving out our own radio and ignored nodes.
+
+The widget holds no lazy collection, which the `ActionTrampolineActivity` removal in `androidApp/src/main/AndroidManifest.xml` relies on.
+
+## Local Stats Content
The widget shows the following information when connected:
@@ -103,19 +125,26 @@ Tapping the widget opens the app. The refresh button fires `RefreshLocalStatsAct
The mesh service holds a reference to `AppWidgetUpdater` (defined in `:core:repository`) without depending on Android widget APIs. `AndroidAppWidgetUpdater` in this module provides the concrete implementation:
```kotlin
-// Simplified — the real updateAll() wraps the call in a try/catch
+// Simplified — the real update wraps each call in a try/catch
@Single
class AndroidAppWidgetUpdater(
private val context: Context,
stateProvider: LocalStatsWidgetStateProvider,
+ trackerDataSource: NodeTrackerWidgetDataSource,
) : AppWidgetUpdater {
override suspend fun updateAll() {
LocalStatsWidget().updateAll(context)
+ NodeTrackerWidget().updateAll(context)
}
}
```
-It also observes `stateProvider.state` itself (debounced 500 ms, ignoring timestamp-only changes) and re-renders whenever widget instances exist — Glance compositions are ephemeral, so re-renders must be driven externally. This keeps the service layer platform-agnostic while still enabling widget refresh on data changes.
+It also drives re-renders itself, because Glance compositions are ephemeral:
+
+- Local Stats: observes `stateProvider.state` (debounced 500 ms, ignoring timestamp-only changes) while instances exist.
+- Node Tracker: samples `trackerDataSource.inputs` once a second, maps it to each instance's rendered state, and updates only when one of those changes. Sampling rather than debouncing keeps a busy mesh from starving the update.
+
+This keeps the service layer platform-agnostic while still enabling widget refresh on data changes.
## Dependency Graph
@@ -123,8 +152,9 @@ It also observes `stateProvider.state` itself (debounced 500 ms, ignoring timest
```text
feature:widget (Android only)
- ├── core:common, core:model, core:resources, core:repository
+ ├── core:common, core:model, core:navigation, core:resources, core:repository, core:ui
├── androidx.glance.appwidget, glance.material3, glance.preview, glance.appwidget.preview
+ ├── androidx.activity.compose, compose.multiplatform.material3 (NodeTrackerConfigActivity)
└── compose.multiplatform.ui (LocalConfiguration, LocalDensity)
```
@@ -134,8 +164,10 @@ graph TB
:feature:widget[widget]:::android-feature
:feature:widget -.-> :core:common
:feature:widget -.-> :core:model
+ :feature:widget -.-> :core:navigation
:feature:widget -.-> :core:resources
:feature:widget -.-> :core:repository
+ :feature:widget -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts
index e7543ae7ffe..ee86b711688 100644
--- a/feature/widget/build.gradle.kts
+++ b/feature/widget/build.gradle.kts
@@ -35,10 +35,16 @@ android {
dependencies {
implementation(projects.core.common)
implementation(projects.core.model)
+ implementation(projects.core.navigation)
implementation(projects.core.resources)
implementation(projects.core.repository)
+ implementation(projects.core.ui)
implementation(libs.compose.multiplatform.ui) // LocalConfiguration, LocalDensity
+ implementation(libs.compose.multiplatform.foundation)
+ implementation(libs.compose.multiplatform.material3)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.datastore.preferences) // Glance per-instance state; Glance ships it runtime-only
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.glance.preview)
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
index 42c3acd03fa..c3223f20a5b 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
@@ -17,8 +17,12 @@
package org.meshtastic.feature.widget
import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.updateAll
+import androidx.glance.state.PreferencesGlanceStateDefinition
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -27,15 +31,21 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AppWidgetUpdater
private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L
+private const val TRACKER_UPDATE_SAMPLE_MS = 1_000L
@Single
-class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) :
- AppWidgetUpdater {
+class AndroidAppWidgetUpdater(
+ private val context: Context,
+ stateProvider: LocalStatsWidgetStateProvider,
+ trackerDataSource: NodeTrackerWidgetDataSource,
+) : AppWidgetUpdater {
@Suppress("InjectDispatcher") // feature:widget does not depend on core:di, where CoroutineDispatchers lives
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -48,21 +58,42 @@ class AndroidAppWidgetUpdater(private val context: Context, stateProvider: Local
stateProvider.state
.debounce(WIDGET_UPDATE_DEBOUNCE_MS)
.distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) }
- .collect { if (hasWidgetInstances()) updateAll() }
+ .collect { if (hasInstances(LocalStatsWidget::class.java)) update(LocalStatsWidget()) }
+ }
+
+ // Sampled rather than debounced: on a busy mesh the node DB changes faster than any debounce window, which
+ // would starve the trackers. Only the rendered state of the tracked nodes counts as a change.
+ @OptIn(FlowPreview::class)
+ scope.launch {
+ trackerDataSource.inputs
+ .sample(TRACKER_UPDATE_SAMPLE_MS)
+ .map { inputs -> trackedNodeNums().map { nodeTrackerState(it, inputs) } }
+ .distinctUntilChanged()
+ .collect { states -> if (states.isNotEmpty()) update(NodeTrackerWidget()) }
}
}
- private suspend fun hasWidgetInstances(): Boolean =
- GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty()
+ private suspend fun hasInstances(widget: Class): Boolean =
+ GlanceAppWidgetManager(context).getGlanceIds(widget).isNotEmpty()
+
+ private suspend fun trackedNodeNums(): List =
+ GlanceAppWidgetManager(context).getGlanceIds(NodeTrackerWidget::class.java).map { id ->
+ getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[TRACKED_NODE_NUM_KEY]
+ }
override suspend fun updateAll() {
+ update(LocalStatsWidget())
+ update(NodeTrackerWidget())
+ }
+
+ private suspend fun update(widget: GlanceAppWidget) {
@Suppress("TooGenericExceptionCaught")
try {
- LocalStatsWidget().updateAll(context)
+ widget.updateAll(context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- Logger.e(e) { "Failed to update widgets" }
+ Logger.e(e) { "Failed to update ${widget::class.simpleName}" }
}
}
}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
index a3a60d5885a..8cc6cdc03c8 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.widget
-import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
@@ -24,7 +23,6 @@ import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -47,7 +45,6 @@ import androidx.glance.appwidget.components.Scaffold
import androidx.glance.appwidget.components.TitleBar
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
-import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
@@ -61,7 +58,6 @@ import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
-import androidx.glance.unit.ColorProvider
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
import org.koin.core.component.KoinComponent
@@ -196,7 +192,7 @@ class LocalStatsWidget :
// Summary Header: Node Chip + Battery
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
state.nodeShortName?.let { name ->
- state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) }
+ state.nodeColors?.let { colors -> WidgetNodeChip(shortName = name, colors = colors) }
}
Spacer(GlanceModifier.width(8.dp))
if (state.hasBattery) {
@@ -403,27 +399,6 @@ class LocalStatsWidget :
}
}
- @SuppressLint("RestrictedApi")
- @Composable
- private fun NodeChip(shortName: String, colors: Pair, modifier: GlanceModifier = GlanceModifier) {
- val (fg, bg) = colors
- Row(
- modifier =
- modifier
- .width(64.dp)
- .background(Color(bg))
- .cornerRadius(4.dp)
- .padding(horizontal = 6.dp, vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = shortName,
- style = TextStyle(color = ColorProvider(Color(fg)), fontSize = 11.sp, fontWeight = FontWeight.Bold),
- )
- }
- }
-
@Composable
private fun StatRow(
label: String,
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt
new file mode 100644
index 00000000000..76c129c7fae
--- /dev/null
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.widget
+
+import org.meshtastic.core.model.Node
+
+/**
+ * The nodes the tracker picker offers: favorites first, then most recently heard, filtered by [query] against the long
+ * name, short name and node id. Our own radio is left out, since Local Stats already covers it, and so are ignored
+ * nodes.
+ */
+internal fun trackerCandidates(nodes: Collection, query: String, ourNodeNum: Int?): List {
+ val needle = query.trim()
+ return nodes
+ .asSequence()
+ .filter { !it.isIgnored && it.num != ourNodeNum }
+ .filter { node ->
+ needle.isEmpty() ||
+ node.user.long_name.contains(needle, ignoreCase = true) ||
+ node.user.short_name.contains(needle, ignoreCase = true) ||
+ node.user.id.contains(needle, ignoreCase = true)
+ }
+ .sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }.thenBy { it.num })
+ .toList()
+}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt
new file mode 100644
index 00000000000..dad8002e482
--- /dev/null
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.widget
+
+import android.app.UiModeManager
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.lifecycle.lifecycleScope
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.ConnectionStateProvider
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.doc_no_results
+import org.meshtastic.core.resources.node_filter_placeholder
+import org.meshtastic.core.resources.nodes_empty_disconnected_hint
+import org.meshtastic.core.resources.nodes_empty_disconnected_title
+import org.meshtastic.core.resources.nodes_empty_searching_hint
+import org.meshtastic.core.resources.nodes_empty_searching_title
+import org.meshtastic.core.resources.widget_node_tracker_choose
+import org.meshtastic.core.ui.component.EmptyState
+import org.meshtastic.core.ui.component.MeshtasticSearchBar
+import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.NoDevice
+import org.meshtastic.core.ui.icon.Nodes
+import org.meshtastic.core.ui.icon.Search
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.theme.MODE_DYNAMIC
+import org.meshtastic.core.ui.util.formatAgo
+
+/**
+ * Picks the node a [NodeTrackerWidget] instance tracks. The launcher opens it when the widget is placed or
+ * reconfigured, and the widget opens it when its node has gone.
+ */
+class NodeTrackerConfigActivity :
+ ComponentActivity(),
+ KoinComponent {
+
+ private val nodeRepository: NodeRepository by inject()
+ private val connectionStateProvider: ConnectionStateProvider by inject()
+ private val uiPrefs: UiPrefs by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Backing out must cancel placement, so the result is CANCELED until a node is picked.
+ setResult(RESULT_CANCELED)
+
+ val appWidgetId =
+ intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ ?: AppWidgetManager.INVALID_APPWIDGET_ID
+ // Exported for the launcher, so only ids bound to our own tracker provider are honored.
+ val provider = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider
+ if (provider != ComponentName(this, NodeTrackerWidgetReceiver::class.java)) {
+ finish()
+ return
+ }
+
+ enableEdgeToEdge()
+ setContent {
+ val theme by uiPrefs.theme.collectAsState()
+ // UiPrefs.theme holds AppCompatDelegate night modes, whose values UiModeManager shares.
+ val dark =
+ when (theme) {
+ UiModeManager.MODE_NIGHT_YES -> true
+ UiModeManager.MODE_NIGHT_NO -> false
+ else -> isSystemInDarkTheme()
+ }
+ AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) {
+ // One snapshot: a live list re-sorts by last heard under the user's finger on a busy mesh. Read the DB
+ // directly, since the repository's flow opens on an empty map; an empty DB waits for its first nodes.
+ val nodes by
+ produceState