/* * 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 . */ 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.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.util.formatUptime 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.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 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 = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, ) }, ) }, modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity()), ) { if (state.showContent) { FullStatsContent(state) } else { Disconnected(state) } } } } } @Composable @Suppress("LongMethod", "MagicNumber") 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)) if (state.hasBattery) { val isPowered = state.batteryLevel > 100 val batteryValue = if (isPowered) stringResource(Res.string.powered) else "${state.batteryLevel}%" StatRow( label = stringResource(Res.string.battery), value = batteryValue, progress = state.batteryProgress, isSmall = isSmall, modifier = GlanceModifier.defaultWeight(), ) } else { Spacer(GlanceModifier.defaultWeight()) } } Spacer(GlanceModifier.height(2.dp)) // Utilization Stats Row(modifier = GlanceModifier.fillMaxWidth()) { StatRow( label = stringResource(Res.string.channel_utilization), value = "%.1f%%".format(state.channelUtilization), progress = state.channelUtilizationProgress, isSmall = isSmall, modifier = GlanceModifier.defaultWeight().padding(end = 4.dp), ) StatRow( label = stringResource(Res.string.air_utilization), value = "%.1f%%".format(state.airUtilization), 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()) { if (state.hasStats) { StatText( stringResource( Res.string.local_stats_traffic, state.numPacketsTx, state.numPacketsRx, state.numRxDupe, ), isSmall, ) if (state.numTxRelay > 0 || state.numTxRelayCanceled > 0) { StatText( stringResource( Res.string.local_stats_relays, state.numTxRelay, state.numTxRelayCanceled, ), isSmall, ) } val diag = mutableListOf() if (state.noiseFloor != 0) { diag.add(stringResource(Res.string.local_stats_noise, state.noiseFloor)) } if (state.numPacketsRxBad > 0) { diag.add(stringResource(Res.string.local_stats_bad, state.numPacketsRxBad)) } if (state.numTxDropped > 0) { diag.add(stringResource(Res.string.local_stats_dropped, state.numTxDropped)) } if (diag.isNotEmpty()) { StatText( stringResource(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | ")), isSmall, ) } val heapProgress = if (state.heapTotalBytes > 0) { state.heapFreeBytes.toFloat() / state.heapTotalBytes } else { 0f } val heapValue = stringResource(Res.string.local_stats_heap_value, state.heapFreeBytes, state.heapTotalBytes) StatRow(stringResource(Res.string.local_stats_heap), 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), ) } val statusText = when (state.connectionState) { is ConnectionState.Disconnected -> stringResource(Res.string.disconnected) is ConnectionState.Connecting -> stringResource(Res.string.connecting) is ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping) is ConnectionState.Connected -> "" } Text( text = statusText, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Medium, ), ) } } @Suppress("LongMethod") @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 = stringResource(Res.string.nodes), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), ) Text( text = "${state.onlineNodes}/${state.totalNodes}", maxLines = 1, style = TextStyle( color = GlanceTheme.colors.onSurface, fontSize = 11.sp, fontWeight = FontWeight.Medium, ), ) } Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) { Text( text = stringResource(Res.string.uptime), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), ) Text( text = formatUptime(state.uptimeSecs.toInt()), maxLines = 1, style = TextStyle( color = GlanceTheme.colors.onSurface, fontSize = 11.sp, fontWeight = FontWeight.Medium, ), ) } } Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { val updatedLabel = stringResource(Res.string.updated) val updatedText = stringResource( Res.string.local_stats_updated_at, DateFormatter.formatShortDate(state.updateTimeMillis), ) val footerText = if (updatedLabel.isNotEmpty()) { "$updatedLabel $updatedText" } else { 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, 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 fun createMockWidgetState() = LocalStatsWidgetUiState( connectionState = ConnectionState.Connected, showContent = true, nodeShortName = "ME", nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(), batteryLevel = 85, hasBattery = true, batteryProgress = 0.85f, channelUtilization = 18.5f, channelUtilizationProgress = 0.185f, airUtilization = 3.2f, airUtilizationProgress = 0.032f, hasStats = true, numPacketsTx = 145, numPacketsRx = 892, numRxDupe = 42, totalNodes = 3, onlineNodes = 2, uptimeSecs = 172800L, updateTimeMillis = System.currentTimeMillis() - 300000L, )