From 1a9771ea280efb3c5bf999c8224d77606fff5cb3 Mon Sep 17 00:00:00 2001
From: Phil Oliver <3497406+poliver@users.noreply.github.com>
Date: Wed, 20 Aug 2025 18:44:04 -0400
Subject: [PATCH] Add modern battery info component (#2801)
---
.../common/components/MaterialBatteryInfo.kt | 129 ++++++++++++
.../mesh/ui/common/icons/Battery.kt | 184 ++++++++++++++++++
2 files changed, 313 insertions(+)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/common/components/MaterialBatteryInfo.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/common/icons/Battery.kt
diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/MaterialBatteryInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/MaterialBatteryInfo.kt
new file mode 100644
index 000000000..2ff3b1fc1
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/MaterialBatteryInfo.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025 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.ui.common.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.BatteryUnknown
+import androidx.compose.material.icons.rounded.BatteryUnknown
+import androidx.compose.material.icons.rounded.Power
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.ui.common.icons.BatteryEmpty
+import com.geeksville.mesh.ui.common.icons.BatteryUnknown
+import com.geeksville.mesh.ui.common.theme.AppTheme
+import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
+import com.geeksville.mesh.ui.common.theme.StatusColors.StatusOrange
+import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
+
+private const val FORMAT = "%d%%"
+private const val SIZE_ICON = 20
+
+@Suppress("MagicNumber")
+@Composable
+fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int) {
+ val levelString = FORMAT.format(level)
+
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ if (level > 100) {
+ Icon(
+ modifier = Modifier.size(SIZE_ICON.dp).rotate(90f),
+ imageVector = Icons.Rounded.Power,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ )
+
+ Text(text = "PWD", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge)
+ } else if (level < 0) {
+ Icon(
+ modifier = Modifier.size(SIZE_ICON.dp),
+ imageVector = BatteryUnknown,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ )
+ } else {
+ // Map battery percentage to color
+ val fillColor =
+ when (level) {
+ in 0..19 -> MaterialTheme.colorScheme.StatusRed
+ in 20..39 -> MaterialTheme.colorScheme.StatusOrange
+ else -> MaterialTheme.colorScheme.StatusGreen
+ }
+
+ Icon(
+ modifier =
+ Modifier.size(SIZE_ICON.dp).drawBehind {
+ val insetVertical = size.height * .28f
+ val insetLeft = size.width * .11f
+ val insetRight = size.width * .22f
+
+ val availableWidth = size.width - (insetLeft + insetRight)
+ val availableHeight = size.height - (insetVertical * 2)
+
+ // Fill (grow from left to right)
+ val fillWidth = availableWidth * (level / 100f)
+
+ drawRect(
+ color = fillColor,
+ topLeft = Offset(insetLeft, insetVertical),
+ size = Size(fillWidth, availableHeight),
+ )
+ },
+ imageVector = BatteryEmpty,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ )
+
+ Text(
+ text = levelString,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+}
+
+class BatteryLevelProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(-1, 19, 39, 90, 101)
+}
+
+@PreviewLightDark
+@Composable
+fun MaterialBatteryInfoPreview(@PreviewParameter(BatteryLevelProvider::class) batteryLevel: Int) {
+ AppTheme { MaterialBatteryInfo(level = batteryLevel) }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/icons/Battery.kt b/app/src/main/java/com/geeksville/mesh/ui/common/icons/Battery.kt
new file mode 100644
index 000000000..382844980
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/common/icons/Battery.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2025 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.ui.common.icons
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+/**
+ * This is from Material Symbols.
+ *
+ * @see
+ * [battery_android_0](https://fonts.google.com/icons?selected=Material+Symbols+Outlined:battery_android_0:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=battery&icon.set=Material+Symbols&icon.size=24&icon.color=%23000000&icon.platform=android)
+ */
+val BatteryEmpty: ImageVector
+ get() {
+ if (batteryEmpty != null) {
+ return batteryEmpty!!
+ }
+ batteryEmpty =
+ ImageVector.Builder(
+ name = "BatteryEmpty",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 960f,
+ viewportHeight = 960f,
+ )
+ .apply {
+ path(fill = SolidColor(Color.Black)) {
+ moveTo(160f, 720f)
+ quadToRelative(-50f, 0f, -85f, -35f)
+ reflectiveQuadToRelative(-35f, -85f)
+ verticalLineToRelative(-240f)
+ quadToRelative(0f, -50f, 35f, -85f)
+ reflectiveQuadToRelative(85f, -35f)
+ horizontalLineToRelative(540f)
+ quadToRelative(50f, 0f, 85f, 35f)
+ reflectiveQuadToRelative(35f, 85f)
+ verticalLineToRelative(240f)
+ quadToRelative(0f, 50f, -35f, 85f)
+ reflectiveQuadToRelative(-85f, 35f)
+ lineTo(160f, 720f)
+ close()
+ moveTo(160f, 640f)
+ horizontalLineToRelative(540f)
+ quadToRelative(17f, 0f, 28.5f, -11.5f)
+ reflectiveQuadTo(740f, 600f)
+ verticalLineToRelative(-240f)
+ quadToRelative(0f, -17f, -11.5f, -28.5f)
+ reflectiveQuadTo(700f, 320f)
+ lineTo(160f, 320f)
+ quadToRelative(-17f, 0f, -28.5f, 11.5f)
+ reflectiveQuadTo(120f, 360f)
+ verticalLineToRelative(240f)
+ quadToRelative(0f, 17f, 11.5f, 28.5f)
+ reflectiveQuadTo(160f, 640f)
+ close()
+ moveTo(860f, 580f)
+ verticalLineToRelative(-200f)
+ horizontalLineToRelative(20f)
+ quadToRelative(17f, 0f, 28.5f, 11.5f)
+ reflectiveQuadTo(920f, 420f)
+ verticalLineToRelative(120f)
+ quadToRelative(0f, 17f, -11.5f, 28.5f)
+ reflectiveQuadTo(880f, 580f)
+ horizontalLineToRelative(-20f)
+ close()
+ moveTo(120f, 640f)
+ verticalLineToRelative(-320f)
+ verticalLineToRelative(320f)
+ close()
+ }
+ }
+ .build()
+
+ return batteryEmpty!!
+ }
+
+private var batteryEmpty: ImageVector? = null
+
+/**
+ * This is from Material Symbols.
+ *
+ * @see
+ * [battery_android_question](https://fonts.google.com/icons?selected=Material+Symbols+Outlined:battery_android_question:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=battery&icon.set=Material+Symbols&icon.size=24&icon.color=%23000000&icon.platform=android)
+ */
+val BatteryUnknown: ImageVector
+ get() {
+ if (batteryUnknown != null) {
+ return batteryUnknown!!
+ }
+ batteryUnknown =
+ ImageVector.Builder(
+ name = "BatteryUnknown",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 960f,
+ viewportHeight = 960f,
+ )
+ .apply {
+ path(fill = SolidColor(Color.Black)) {
+ moveTo(120f, 640f)
+ verticalLineToRelative(-320f)
+ verticalLineToRelative(320f)
+ close()
+ moveTo(726f, 720f)
+ lineTo(160f, 720f)
+ quadToRelative(-50f, 0f, -85f, -35f)
+ reflectiveQuadToRelative(-35f, -85f)
+ verticalLineToRelative(-240f)
+ quadToRelative(0f, -50f, 35f, -85f)
+ reflectiveQuadToRelative(85f, -35f)
+ horizontalLineToRelative(521f)
+ quadToRelative(-20f, 16f, -35f, 36f)
+ reflectiveQuadToRelative(-25f, 44f)
+ lineTo(160f, 320f)
+ quadToRelative(-17f, 0f, -28.5f, 11.5f)
+ reflectiveQuadTo(120f, 360f)
+ verticalLineToRelative(240f)
+ quadToRelative(0f, 17f, 11.5f, 28.5f)
+ reflectiveQuadTo(160f, 640f)
+ horizontalLineToRelative(520f)
+ quadToRelative(2f, 25f, 14.5f, 45.5f)
+ reflectiveQuadTo(726f, 720f)
+ close()
+ moveTo(800f, 660f)
+ quadToRelative(17f, 0f, 28.5f, -11.5f)
+ reflectiveQuadTo(840f, 620f)
+ quadToRelative(0f, -17f, -11.5f, -28.5f)
+ reflectiveQuadTo(800f, 580f)
+ quadToRelative(-17f, 0f, -28.5f, 11.5f)
+ reflectiveQuadTo(760f, 620f)
+ quadToRelative(0f, 17f, 11.5f, 28.5f)
+ reflectiveQuadTo(800f, 660f)
+ close()
+ moveTo(772f, 538f)
+ horizontalLineToRelative(57f)
+ verticalLineToRelative(-21f)
+ quadToRelative(0f, -10f, 5f, -19f)
+ quadToRelative(6f, -13f, 15.5f, -22f)
+ reflectiveQuadToRelative(19.5f, -19f)
+ quadToRelative(17f, -17f, 28.5f, -37f)
+ reflectiveQuadToRelative(11.5f, -43f)
+ quadToRelative(0f, -42f, -32.5f, -69.5f)
+ reflectiveQuadTo(800f, 280f)
+ quadToRelative(-38f, 0f, -68f, 22f)
+ reflectiveQuadToRelative(-40f, 58f)
+ lineToRelative(51f, 21f)
+ quadToRelative(6f, -20f, 21.5f, -33f)
+ reflectiveQuadToRelative(35.5f, -13f)
+ quadToRelative(21f, 0f, 36.5f, 12f)
+ reflectiveQuadToRelative(15.5f, 32f)
+ quadToRelative(0f, 17f, -10f, 30.5f)
+ reflectiveQuadTo(820f, 434f)
+ quadToRelative(-11f, 11f, -22.5f, 21.5f)
+ reflectiveQuadTo(779f, 480f)
+ quadToRelative(-6f, 14f, -6.5f, 28.5f)
+ reflectiveQuadTo(772f, 538f)
+ close()
+ }
+ }
+ .build()
+
+ return batteryUnknown!!
+ }
+
+private var batteryUnknown: ImageVector? = null