mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
482 lines
20 KiB
Kotlin
482 lines
20 KiB
Kotlin
/*
|
|
* 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.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<RefreshLocalStatsAction>(),
|
|
backgroundColor = null,
|
|
)
|
|
},
|
|
)
|
|
},
|
|
modifier =
|
|
GlanceModifier.fillMaxSize().clickable(actionStartActivity<com.geeksville.mesh.MainActivity>()),
|
|
) {
|
|
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<String>()
|
|
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<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 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,
|
|
)
|