feat(widget): Add Local Stats glance widget (#4642)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-25 13:39:00 -06:00 committed by GitHub
parent 692ad78c80
commit 9970d31520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1256 additions and 24 deletions

View file

@ -0,0 +1,412 @@
/*
* Copyright (c) 2025-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 com.geeksville.mesh.widget
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.CircularProgressIndicator
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.LinearProgressIndicator
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.components.CircleIconButton
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
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
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 dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.battery
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.service.ConnectionState
class LocalStatsWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
override val previewSizeMode: androidx.glance.appwidget.PreviewSizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LocalStatsWidgetEntryPoint {
fun widgetStateProvider(): LocalStatsWidgetStateProvider
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint =
EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
val stateProvider = entryPoint.widgetStateProvider()
provideContent {
val state by stateProvider.state.collectAsState()
WidgetContent(state)
}
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
val entryPoint =
EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
val stateProvider = entryPoint.widgetStateProvider()
val currentState = stateProvider.state.value
val stateToRender =
if (currentState.showContent && currentState.nodeShortName != null) {
currentState
} else {
createMockWidgetState()
}
provideContent { WidgetContent(stateToRender) }
}
@Composable
internal fun WidgetContent(state: LocalStatsWidgetUiState) {
val context = LocalContext.current
CompositionLocalProvider(
androidx.compose.ui.platform.LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalDensity provides Density(context.resources.displayMetrics.density),
) {
GlanceTheme {
Scaffold(
titleBar = {
TitleBar(
startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
title = state.appName,
actions = {
CircleIconButton(
imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh),
contentDescription = state.refreshLabel,
onClick = actionRunCallback<RefreshLocalStatsAction>(),
backgroundColor = null,
)
},
)
},
modifier =
GlanceModifier.fillMaxSize().clickable(actionStartActivity<com.geeksville.mesh.MainActivity>()),
) {
if (state.showContent) {
FullStatsContent(state)
} else {
Disconnected(state)
}
}
}
}
}
@Composable
private fun FullStatsContent(state: LocalStatsWidgetUiState) {
val size = LocalSize.current
val isNarrow = size.width < 160.dp
val isShort = size.height < 110.dp
val isSmall = isNarrow || isShort
Column(modifier = GlanceModifier.fillMaxSize()) {
// Main Stats Container
Column(modifier = GlanceModifier.defaultWeight()) {
// 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) }
}
Spacer(GlanceModifier.width(8.dp))
StatRow(
label = state.batteryLabel,
value = state.batteryValue,
progress = state.batteryProgress,
isSmall = isSmall,
modifier = GlanceModifier.defaultWeight(),
)
}
Spacer(GlanceModifier.height(2.dp))
// Utilization Stats
Row(modifier = GlanceModifier.fillMaxWidth()) {
StatRow(
label = state.channelUtilizationLabel,
value = state.channelUtilizationValue,
progress = state.channelUtilizationProgress,
isSmall = isSmall,
modifier = GlanceModifier.defaultWeight().padding(end = 4.dp),
)
StatRow(
label = state.airUtilizationLabel,
value = state.airUtilizationValue,
progress = state.airUtilizationProgress,
isSmall = isSmall,
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp),
)
}
// Detailed Traffic/Relay Stats
Spacer(GlanceModifier.height(2.dp))
Column(modifier = GlanceModifier.fillMaxWidth()) {
state.trafficText?.let { StatText(it, isSmall) }
state.relayText?.let { StatText(it, isSmall) }
state.diagnosticsText?.let { StatText(it, isSmall) }
state.heapText?.let {
val heapProgress =
if (state.heapTotalBytes > 0) {
state.heapFreeBytes.toFloat() / state.heapTotalBytes
} else {
0f
}
StatRow(it, state.heapValue, heapProgress, isSmall)
}
}
}
// Footer (Nodes + Uptime - Pinned to bottom)
Footer(state)
}
}
@Composable
private fun StatText(text: String, isSmall: Boolean) {
Text(
text = text,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = if (isSmall) 9.sp else 10.sp),
modifier = GlanceModifier.fillMaxWidth(),
)
}
@Composable
private fun Disconnected(state: LocalStatsWidgetUiState) {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (state.isConnecting) {
CircularProgressIndicator(modifier = GlanceModifier.size(24.dp))
} else {
Image(
provider = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
contentDescription = null,
modifier = GlanceModifier.size(32.dp),
)
}
Text(
text = state.statusText,
style =
TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
)
}
}
@Composable
private fun Footer(state: LocalStatsWidgetUiState) {
Column(modifier = GlanceModifier.fillMaxWidth()) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) {
Text(
text = state.nodesLabel,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
)
Text(
text = state.nodeCountText,
maxLines = 1,
style =
TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
),
)
}
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
Text(
text = state.uptimeLabel,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
)
Text(
text = state.uptimeText,
maxLines = 1,
style =
TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
),
)
}
}
Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
val footerText =
if (state.updatedLabel.isNotEmpty()) {
"${state.updatedLabel} ${state.updatedText}"
} else {
state.updatedText
}
Text(
text = footerText,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 8.sp),
modifier = GlanceModifier.padding(bottom = 2.dp),
maxLines = 1,
)
}
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun NodeChip(shortName: String, colors: Pair<Int, Int>, 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,
value: String?,
progress: Float,
isSmall: Boolean,
modifier: GlanceModifier = GlanceModifier,
) {
Column(modifier = modifier.padding(vertical = 2.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style =
TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = if (isSmall) 10.sp else 11.sp,
),
modifier = GlanceModifier.defaultWeight(),
)
value?.let {
Text(
text = it,
style =
TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
),
)
}
}
Spacer(GlanceModifier.height(2.dp))
LinearProgressIndicator(
progress = progress,
modifier = GlanceModifier.fillMaxWidth().height(4.dp).cornerRadius(2.dp),
color = GlanceTheme.colors.primary,
backgroundColor = GlanceTheme.colors.surfaceVariant,
)
}
}
companion object {
private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
private val BIG_SQUARE = DpSize(250.dp, 250.dp)
private val RESPONSIVE_SIZES = setOf(SMALL_SQUARE, HORIZONTAL_RECTANGLE, BIG_SQUARE)
}
}
internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState(
connectionState = ConnectionState.Connected,
showContent = true,
appName = getStringSuspend(Res.string.meshtastic_app_name),
nodesLabel = getStringSuspend(Res.string.nodes),
uptimeLabel = getStringSuspend(Res.string.uptime),
updatedLabel = getStringSuspend(Res.string.updated),
refreshLabel = getStringSuspend(Res.string.refresh),
nodeShortName = "ME",
nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(),
batteryLabel = getStringSuspend(Res.string.battery),
batteryValue = "85%",
batteryProgress = 0.85f,
channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
channelUtilizationValue = "18.5%",
channelUtilizationProgress = 0.185f,
airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
airUtilizationValue = "3.2%",
airUtilizationProgress = 0.032f,
trafficText = "TX: 145 | RX: 892 | D: 42",
nodeCountText = "2/3",
uptimeText = "2d 0h",
updatedText = "5m ago",
)

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025-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 com.geeksville.mesh.widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class LocalStatsWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = LocalStatsWidget()
}

View file

@ -0,0 +1,251 @@
/*
* Copyright (c) 2025-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 com.geeksville.mesh.widget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.battery
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
import org.meshtastic.core.resources.local_stats_dropped
import org.meshtastic.core.resources.local_stats_heap
import org.meshtastic.core.resources.local_stats_heap_value
import org.meshtastic.core.resources.local_stats_noise
import org.meshtastic.core.resources.local_stats_relays
import org.meshtastic.core.resources.local_stats_traffic
import org.meshtastic.core.resources.local_stats_updated_at
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
import javax.inject.Singleton
data class LocalStatsWidgetUiState(
val connectionState: ConnectionState = ConnectionState.Disconnected,
// Rendering data
val statusText: String = "",
val isConnecting: Boolean = false,
val showContent: Boolean = false,
// Static Strings (Resolved in provider for Glance stability)
val appName: String = "",
val nodesLabel: String = "",
val uptimeLabel: String = "",
val updatedLabel: String = "",
val refreshLabel: String = "",
// Node Identity
val nodeShortName: String? = null,
val nodeColors: Pair<Int, Int>? = null,
// Battery
val batteryLabel: String = "",
val batteryValue: String = "",
val batteryProgress: Float = 0f,
// Utilization
val channelUtilizationLabel: String = "",
val channelUtilizationValue: String = "",
val channelUtilizationProgress: Float = 0f,
val airUtilizationLabel: String = "",
val airUtilizationValue: String = "",
val airUtilizationProgress: Float = 0f,
// Packet Stats Lines
val trafficText: String? = null,
val relayText: String? = null,
val diagnosticsText: String? = null,
val heapFreeBytes: Int = 0,
val heapTotalBytes: Int = 0,
val heapValue: String? = null,
val heapText: String? = null,
// Footer
val nodeCountText: String = "",
val uptimeText: String = "",
val updatedText: String = "",
)
@Singleton
class LocalStatsWidgetStateProvider
@Inject
constructor(
nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val state: StateFlow<LocalStatsWidgetUiState> =
combine(
serviceRepository.connectionState,
nodeRepository.nodeDBbyNum
.map { nodes ->
val online = nodes.values.count { it.lastHeard > onlineTimeThreshold() }
nodes.size to online
}
.distinctUntilChanged(),
nodeRepository.localStats,
nodeRepository.ourNodeInfo,
) { connectionState, (totalNodes, onlineNodes), stats, localNode ->
StateInput(connectionState, totalNodes, onlineNodes, stats, localNode)
}
.map { input ->
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
}
.distinctUntilChanged()
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = LocalStatsWidgetUiState(),
)
private data class StateInput(
val connectionState: ConnectionState,
val totalNodes: Int,
val onlineNodes: Int,
val stats: LocalStats,
val localNode: Node?,
)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
private suspend fun mapToUiState(
connectionState: ConnectionState,
totalNodes: Int,
onlineNodes: Int,
stats: LocalStats,
localNode: Node?,
): LocalStatsWidgetUiState {
val statusText =
when (connectionState) {
is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected)
is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping)
is ConnectionState.Connected -> ""
}
val metrics = localNode?.deviceMetrics
val batteryLevel = metrics?.battery_level ?: 0
val isPowered = batteryLevel > 100
val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%"
val hasStats = stats.uptime_seconds != 0
val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
val diag = mutableListOf<String>()
if (hasStats) {
if (stats.noise_floor != 0) {
diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor))
}
if (stats.num_packets_rx_bad > 0) {
diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad))
}
if (stats.num_tx_dropped > 0) {
diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped))
}
}
val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L
return LocalStatsWidgetUiState(
connectionState = connectionState,
statusText = statusText,
isConnecting = connectionState is ConnectionState.Connecting,
showContent = connectionState is ConnectionState.Connected,
appName = getStringSuspend(Res.string.meshtastic_app_name),
nodesLabel = getStringSuspend(Res.string.nodes),
uptimeLabel = getStringSuspend(Res.string.uptime),
updatedLabel = getStringSuspend(Res.string.updated),
refreshLabel = getStringSuspend(Res.string.refresh),
nodeShortName = localNode?.user?.short_name,
nodeColors = localNode?.colors,
batteryLabel = getStringSuspend(Res.string.battery),
batteryValue = batteryValue,
batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f),
channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
channelUtilizationValue = "%.1f%%".format(channelUtil),
channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f),
airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
airUtilizationValue = "%.1f%%".format(airUtilTx),
airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f),
trafficText =
if (hasStats) {
getStringSuspend(
Res.string.local_stats_traffic,
stats.num_packets_tx,
stats.num_packets_rx,
stats.num_rx_dupe,
)
} else {
null
},
relayText =
stats
.takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) }
?.let {
getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled)
},
diagnosticsText =
if (diag.isNotEmpty()) {
getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | "))
} else {
null
},
heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0,
heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0,
heapValue =
if (hasStats) {
getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes)
} else {
null
},
heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null,
nodeCountText = "$onlineNodes/$totalNodes",
uptimeText = formatUptime(uptimeSecs.toInt()),
updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)),
)
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-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 com.geeksville.mesh.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import com.geeksville.mesh.service.MeshCommandSender
import com.geeksville.mesh.service.MeshNodeManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.TelemetryType
class RefreshLocalStatsAction : ActionCallback {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface RefreshLocalStatsEntryPoint {
fun commandSender(): MeshCommandSender
fun nodeManager(): MeshNodeManager
}
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val entryPoint =
EntryPointAccessors.fromApplication(context.applicationContext, RefreshLocalStatsEntryPoint::class.java)
val commandSender = entryPoint.commandSender()
val nodeManager = entryPoint.nodeManager()
val myNodeNum = nodeManager.myNodeNum ?: return
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
}
}