mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(widget): Add Local Stats glance widget (#4642)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
692ad78c80
commit
9970d31520
23 changed files with 1256 additions and 24 deletions
412
app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
Normal file
412
app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
Normal 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",
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue