diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dcdcd50d2..1bcfa30ed 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -225,14 +225,10 @@ dependencies {
}
val googleServiceKeywords = listOf("crashlytics", "google", "datadog")
+
tasks.configureEach {
if (
- googleServiceKeywords.any {
- name.contains(
- it,
- ignoreCase = true
- )
- } && name.contains("fdroid", ignoreCase = true)
+ googleServiceKeywords.any { name.contains(it, ignoreCase = true) } && name.contains("fdroid", ignoreCase = true)
) {
project.logger.lifecycle("Disabling task for F-Droid: $name")
enabled = false
diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/SignalInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/SignalInfo.kt
index c41958236..7f4223a58 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/common/components/SignalInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/SignalInfo.kt
@@ -17,24 +17,33 @@
package com.geeksville.mesh.ui.common.components
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
-import org.meshtastic.core.ui.component.NodeSignalQuality
+import org.meshtastic.core.ui.component.Rssi
+import org.meshtastic.core.ui.component.Snr
+import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.theme.AppTheme
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
+@Suppress("LongMethod")
@Composable
fun SignalInfo(modifier: Modifier = Modifier, node: Node, isThisNode: Boolean) {
val text =
@@ -60,19 +69,42 @@ fun SignalInfo(modifier: Modifier = Modifier, node: Node, isThisNode: Boolean) {
}
.joinToString(" ")
}
- Column {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
if (text.isNotEmpty()) {
- Text(
- modifier = modifier,
- text = text,
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.bodySmall.fontSize,
- )
+ Text(text = text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall)
}
/* We only know the Signal Quality from direct nodes aka 0 hop. */
if (node.hopsAway <= 0) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
- NodeSignalQuality(node.snr, node.rssi)
+ val quality = determineSignalQuality(node.snr, node.rssi)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Snr(node.snr)
+ Rssi(node.rssi)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = quality.imageVector,
+ contentDescription = stringResource(R.string.signal_quality),
+ tint = quality.color.invoke(),
+ )
+ Text(
+ text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt
index 9701392c4..adcd14593 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt
@@ -174,8 +174,8 @@ internal fun MessageItem(
if (!message.fromLocal) {
if (message.hopsAway == 0) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Snr(message.snr, fontSize = MaterialTheme.typography.labelSmall.fontSize)
- Rssi(message.rssi, fontSize = MaterialTheme.typography.labelSmall.fontSize)
+ Snr(message.snr)
+ Rssi(message.rssi)
}
} else {
Text(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt
index 6e3925af2..f449db185 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt
@@ -70,7 +70,7 @@ import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.plotPoint
import org.meshtastic.core.strings.R
-import org.meshtastic.core.ui.component.BatteryInfo
+import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.AppTheme
@@ -319,7 +319,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry) {
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
- BatteryInfo(batteryLevel = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
+ MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
}
Spacer(modifier = Modifier.height(4.dp))
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
index a31b73de3..7d194b141 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
@@ -18,7 +18,6 @@
package com.geeksville.mesh.ui.node
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
@@ -55,7 +54,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -73,7 +71,7 @@ import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
-@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
@@ -172,11 +170,12 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
onConfirmRemove = { nodesViewModel.removeNode(it.num) },
)
+ var expanded by remember { mutableStateOf(false) }
+
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
- var showContextMenu by remember { mutableStateOf(false) }
val longClick =
if (node.num != ourNode?.num) {
- { showContextMenu = true }
+ { expanded = true }
} else {
null
}
@@ -193,14 +192,16 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
isConnected = connectionState.isConnected(),
)
val isThisNode = remember(node) { ourNode?.num == node.num }
- ContextMenu(
- expanded = !isThisNode && showContextMenu,
- node = node,
- onClickFavorite = { displayFavoriteDialog = true },
- onClickIgnore = { displayIgnoreDialog = true },
- onClickRemove = { displayRemoveDialog = true },
- onDismiss = { showContextMenu = false },
- )
+ if (!isThisNode) {
+ ContextMenu(
+ expanded = expanded,
+ node = node,
+ onClickFavorite = { displayFavoriteDialog = true },
+ onClickIgnore = { displayIgnoreDialog = true },
+ onClickRemove = { displayRemoveDialog = true },
+ onDismiss = { expanded = false },
+ )
+ }
}
}
item { Spacer(modifier = Modifier.height(88.dp)) }
@@ -218,7 +219,7 @@ private fun ContextMenu(
onClickRemove: (Node) -> Unit,
onDismiss: () -> Unit,
) {
- DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = DpOffset(x = 0.dp, y = 8.dp)) {
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
val isFavorite = node.isFavorite
val isIgnored = node.isIgnored
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/DistanceInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/DistanceInfo.kt
new file mode 100644
index 000000000..8ef68fd94
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/DistanceInfo.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.node.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.SocialDistance
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import org.meshtastic.core.strings.R
+import org.meshtastic.core.ui.theme.AppTheme
+
+@Composable
+fun DistanceInfo(distance: String, modifier: Modifier = Modifier) {
+ IconInfo(
+ modifier = modifier,
+ icon = Icons.Rounded.SocialDistance,
+ contentDescription = stringResource(R.string.distance),
+ text = distance,
+ )
+}
+
+@PreviewLightDark
+@Composable
+private fun DistanceInfoPreview() {
+ AppTheme { DistanceInfo(distance = "423 mi.") }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/ElevationInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/ElevationInfo.kt
index bfbdb1015..acbb58aff 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/ElevationInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/ElevationInfo.kt
@@ -18,24 +18,30 @@
package com.geeksville.mesh.ui.node.components
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
+import org.meshtastic.core.strings.R
+import org.meshtastic.core.ui.icon.Elevation
+import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable
-fun ElevationInfo(modifier: Modifier = Modifier, altitude: Int, system: DisplayUnits, suffix: String) {
- val annotatedString = buildAnnotatedString {
- append(altitude.metersIn(system).toString(system))
- MaterialTheme.typography.labelSmall.toSpanStyle().let { style -> withStyle(style) { append(" $suffix") } }
- }
-
- Text(modifier = modifier, fontSize = MaterialTheme.typography.labelLarge.fontSize, text = annotatedString)
+fun ElevationInfo(
+ modifier: Modifier = Modifier,
+ altitude: Int,
+ system: DisplayUnits,
+ suffix: String = stringResource(R.string.elevation_suffix),
+) {
+ IconInfo(
+ modifier = modifier,
+ icon = MeshtasticIcons.Elevation,
+ contentDescription = stringResource(R.string.altitude),
+ text = altitude.metersIn(system).toString(system) + " " + suffix,
+ )
}
@Composable
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/IconInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/IconInfo.kt
new file mode 100644
index 000000000..5d97ba9a9
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/IconInfo.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.node.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+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.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.icon.Elevation
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+private const val SIZE_ICON = 20
+
+@Composable
+fun IconInfo(
+ icon: ImageVector,
+ contentDescription: String,
+ modifier: Modifier = Modifier,
+ text: String? = null,
+ content: @Composable () -> Unit = {},
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Icon(
+ modifier = Modifier.size(SIZE_ICON.dp),
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ text?.let {
+ Text(text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
+ }
+ content()
+ }
+}
+
+@Composable
+@Preview
+private fun IconInfoPreview() {
+ MaterialTheme {
+ IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", content = { Text(text = "100") })
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/LastHeardInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/LastHeardInfo.kt
index 27aa8adc4..d055ca2d5 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/LastHeardInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/LastHeardInfo.kt
@@ -17,37 +17,24 @@
package com.geeksville.mesh.ui.node.components
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.height
-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.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
-import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun LastHeardInfo(modifier: Modifier = Modifier, lastHeard: Int, currentTimeMillis: Long) {
- Row(
+ IconInfo(
modifier = modifier,
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- Icon(
- modifier = Modifier.height(18.dp),
- imageVector = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
- contentDescription = null,
- )
- Text(text = formatAgo(lastHeard, currentTimeMillis), fontSize = MaterialTheme.typography.labelLarge.fontSize)
- }
+ icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
+ contentDescription = stringResource(org.meshtastic.core.strings.R.string.node_sort_last_heard),
+ text = formatAgo(lastHeard, currentTimeMillis),
+ )
}
@PreviewLightDark
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
index 3e17388e5..bd1241c2e 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
@@ -17,7 +17,9 @@
package com.geeksville.mesh.ui.node.components
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,34 +44,52 @@ import com.geeksville.mesh.TelemetryProtos
import org.meshtastic.core.database.model.Node
@Composable
-fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: (Node) -> Unit = {}) {
+fun NodeChip(
+ modifier: Modifier = Modifier,
+ node: Node,
+ onClick: (Node) -> Unit = {},
+ onLongClick: (() -> Unit)? = {},
+ interactionSource: MutableInteractionSource? = null,
+) {
val isIgnored = node.isIgnored
val (textColor, nodeColor) = node.colors
- val inputChipInteractionSource = remember { MutableInteractionSource() }
- ElevatedAssistChip(
- modifier =
- modifier.width(IntrinsicSize.Min).defaultMinSize(minWidth = 72.dp).semantics {
- contentDescription = node.user.shortName.ifEmpty { "Node" }
- },
- elevation = AssistChipDefaults.elevatedAssistChipElevation(),
- colors =
- AssistChipDefaults.elevatedAssistChipColors(
- containerColor = Color(nodeColor),
- labelColor = Color(textColor),
- ),
- label = {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = node.user.shortName.ifEmpty { "???" },
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
- textAlign = TextAlign.Center,
- maxLines = 1,
- )
- },
- onClick = { onClick(node) },
- interactionSource = inputChipInteractionSource,
- )
+ val inputChipInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
+ Box(modifier = modifier) {
+ ElevatedAssistChip(
+ modifier =
+ Modifier.width(IntrinsicSize.Min).defaultMinSize(minWidth = 72.dp).semantics {
+ contentDescription = node.user.shortName.ifEmpty { "Node" }
+ },
+ elevation = AssistChipDefaults.elevatedAssistChipElevation(),
+ colors =
+ AssistChipDefaults.elevatedAssistChipColors(
+ containerColor = Color(nodeColor),
+ labelColor = Color(textColor),
+ ),
+ label = {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = node.user.shortName.ifEmpty { "???" },
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ )
+ },
+ onClick = {},
+ interactionSource = inputChipInteractionSource,
+ )
+ Box(
+ modifier =
+ Modifier.matchParentSize()
+ .combinedClickable(
+ onLongClick = onLongClick,
+ onClick = { onClick(node) },
+ interactionSource = inputChipInteractionSource,
+ indication = null,
+ ),
+ )
+ }
}
@Suppress("MagicNumber")
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt
index b9444d9fc..25cf1dd98 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt
@@ -19,8 +19,10 @@ package com.geeksville.mesh.ui.node.components
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
@@ -28,14 +30,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,9 +56,10 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
-import org.meshtastic.core.ui.component.BatteryInfo
+import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.theme.AppTheme
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeItem(
@@ -77,13 +81,7 @@ fun NodeItem(
val distance =
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
- val style =
- if (thatNode.isUnknownUser) {
- LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
- } else {
- LocalTextStyle.current
- }
-
+ var contentColor = MaterialTheme.colorScheme.onSurface
val cardColors =
if (isThisNode) {
thisNode?.colors?.second
@@ -92,10 +90,17 @@ fun NodeItem(
}
?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
- CardDefaults.cardColors()
- .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
+ contentColor = contentColorFor(containerColor)
+ CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())
+ val style =
+ if (thatNode.isUnknownUser) {
+ LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
+ } else {
+ LocalTextStyle.current
+ }
+
val unmessageable =
remember(thatNode) {
when {
@@ -104,13 +109,23 @@ fun NodeItem(
}
}
- Card(modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 80.dp), colors = cardColors) {
- Column(
- modifier =
- Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(8.dp),
- ) {
+ val interactionSource = remember { MutableInteractionSource() }
+ Card(
+ modifier =
+ modifier
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource)
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 80.dp),
+ colors = cardColors,
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- NodeChip(node = thatNode)
+ NodeChip(
+ node = thatNode,
+ onClick = { onClick() },
+ onLongClick = onLongClick,
+ interactionSource = interactionSource,
+ )
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
@@ -121,7 +136,10 @@ fun NodeItem(
Text(
modifier = Modifier.weight(1f),
text = longName,
- style = style,
+ style =
+ MaterialTheme.typography.titleMediumEmphasized.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
softWrap = true,
)
@@ -133,45 +151,79 @@ fun NodeItem(
isConnected = isConnected,
)
}
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- if (distance != null) {
- Text(text = distance, fontSize = MaterialTheme.typography.labelLarge.fontSize)
- } else {
- Spacer(modifier = Modifier.width(16.dp))
- }
- BatteryInfo(batteryLevel = thatNode.batteryLevel, voltage = thatNode.voltage)
- }
- Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- SignalInfo(node = thatNode, isThisNode = isThisNode)
- thatNode.validPosition?.let { position ->
- val satCount = position.satsInView
- if (satCount > 0) {
- SatelliteCountInfo(satCount = satCount)
+ MaterialBatteryInfo(level = thatNode.batteryLevel, voltage = thatNode.voltage)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (distance != null) {
+ DistanceInfo(distance = distance)
+ }
+ thatNode.validPosition?.let { position ->
+ ElevationInfo(
+ altitude = position.altitude,
+ system = system,
+ suffix = stringResource(id = R.string.elevation_suffix),
+ )
+ val satCount = position.satsInView
+ if (satCount > 0) {
+ SatelliteCountInfo(satCount = satCount)
+ }
}
}
}
Spacer(modifier = Modifier.height(4.dp))
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- val telemetryString = thatNode.getTelemetryString(tempInFahrenheit)
- if (telemetryString.isNotEmpty()) {
- Text(
- text = telemetryString,
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ itemVerticalAlignment = Alignment.CenterVertically,
+ ) {
+ SignalInfo(node = thatNode, isThisNode = isThisNode)
+ }
+ val telemetryStrings = thatNode.getTelemetryStrings(tempInFahrenheit)
+
+ if (telemetryStrings.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(2.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ telemetryStrings.forEach { telemetryString ->
+ Text(
+ text = telemetryString,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
}
}
+ Spacer(modifier = Modifier.height(2.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val labelStyle =
+ if (thatNode.isUnknownUser) {
+ MaterialTheme.typography.labelSmall.copy(
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ } else {
+ MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onSurface)
+ }
+ Text(text = thatNode.user.hwModel.name, style = labelStyle)
+ Text(text = thatNode.user.role.name, style = labelStyle)
+ Text(text = thatNode.user.id.ifEmpty { "???" }, style = labelStyle)
+ }
}
}
}
@Composable
-@Preview(showBackground = false)
+@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
@@ -185,23 +237,12 @@ fun NodeInfoSimplePreview() {
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
- Column {
- Text(text = "Details Collapsed", color = MaterialTheme.colorScheme.onBackground)
- NodeItem(
- thisNode = thisNode,
- thatNode = thatNode,
- distanceUnits = 1,
- tempInFahrenheit = true,
- currentTimeMillis = System.currentTimeMillis(),
- )
- Text(text = "Details Shown", color = MaterialTheme.colorScheme.onBackground)
- NodeItem(
- thisNode = thisNode,
- thatNode = thatNode,
- distanceUnits = 1,
- tempInFahrenheit = true,
- currentTimeMillis = System.currentTimeMillis(),
- )
- }
+ NodeItem(
+ thisNode = thisNode,
+ thatNode = thatNode,
+ distanceUnits = 1,
+ tempInFahrenheit = true,
+ currentTimeMillis = System.currentTimeMillis(),
+ )
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeStatusIcons.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeStatusIcons.kt
index 62c85ab3d..82273a9be 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeStatusIcons.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeStatusIcons.kt
@@ -31,6 +31,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
@@ -51,7 +52,7 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
TooltipBox(
- positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/SatelliteCountInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/SatelliteCountInfo.kt
index 3f1a87b61..581766036 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/SatelliteCountInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/SatelliteCountInfo.kt
@@ -17,40 +17,22 @@
package com.geeksville.mesh.ui.node.components
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.SatelliteAlt
-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.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
-import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun SatelliteCountInfo(modifier: Modifier = Modifier, satCount: Int) {
- Row(
+ IconInfo(
modifier = modifier,
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Icon(
- modifier = Modifier.size(18.dp),
- imageVector = Icons.TwoTone.SatelliteAlt,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = "$satCount",
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- color = MaterialTheme.colorScheme.onSurface,
- )
- }
+ icon = Icons.TwoTone.SatelliteAlt,
+ contentDescription = stringResource(org.meshtastic.core.strings.R.string.sats),
+ text = "$satCount",
+ )
}
@PreviewLightDark
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
index e5d4dc6a3..ca003ea40 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
@@ -119,7 +119,7 @@ data class Node(
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
- private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
+ private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List {
val temp =
if (temperature != 0f) {
if (isFahrenheit) {
@@ -152,15 +152,23 @@ data class Node(
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
- return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
+ return listOfNotNull(
+ paxcounter.getDisplayString(),
+ temp,
+ humidity,
+ soilTemperatureStr,
+ soilMoisture,
+ voltage,
+ current,
+ iaq,
+ )
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
- fun getTelemetryString(isFahrenheit: Boolean = false): String =
- listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
- .joinToString(" ")
+ fun getTelemetryStrings(isFahrenheit: Boolean = false): List =
+ environmentMetrics.getDisplayStrings(isFahrenheit)
}
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BatteryInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BatteryInfo.kt
deleted file mode 100644
index 845d1560c..000000000
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BatteryInfo.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * 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 org.meshtastic.core.ui.component
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.height
-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.graphics.vector.ImageVector
-import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.tooling.preview.Preview
-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 org.meshtastic.core.ui.R
-import org.meshtastic.core.ui.theme.AppTheme
-
-@Composable
-fun BatteryInfo(modifier: Modifier = Modifier, batteryLevel: Int?, voltage: Float?) {
- val infoString = "%d%% %.2fV".format(batteryLevel, voltage)
- val (image, level) =
- when (batteryLevel) {
- in 0..4 -> R.drawable.ic_battery_alert to " $infoString"
- in 5..14 -> R.drawable.ic_battery_outline to infoString
- in 15..34 -> R.drawable.ic_battery_low to infoString
- in 35..79 -> R.drawable.ic_battery_medium to infoString
- in 80..100 -> R.drawable.ic_battery_high to infoString
- 101 -> R.drawable.ic_power_plug_24 to "%.2fV".format(voltage)
- else -> R.drawable.ic_battery_unknown to (voltage?.let { "%.2fV".format(it) } ?: "")
- }
-
- Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
- Icon(
- modifier = Modifier.height(18.dp),
- imageVector = ImageVector.vectorResource(id = image),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = level,
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- }
-}
-
-@PreviewLightDark
-@Composable
-fun BatteryInfoPreview(@PreviewParameter(BatteryInfoPreviewParameterProvider::class) batteryInfo: Pair) {
- AppTheme { BatteryInfo(batteryLevel = batteryInfo.first, voltage = batteryInfo.second) }
-}
-
-@Composable
-@Preview
-fun BatteryInfoPreviewSimple() {
- AppTheme { BatteryInfo(batteryLevel = 85, voltage = 3.7F) }
-}
-
-class BatteryInfoPreviewParameterProvider : PreviewParameterProvider> {
- override val values: Sequence>
- get() =
- sequenceOf(
- 85 to 3.7F,
- 2 to 3.7F,
- 12 to 3.7F,
- 28 to 3.7F,
- 50 to 3.7F,
- 101 to 4.9F,
- null to 4.5F,
- null to null,
- )
-}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index 6975d5bcb..ab1197df5 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -24,11 +24,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.SignalCellularAlt
@@ -45,7 +44,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
@@ -60,7 +58,7 @@ const val RSSI_GOOD_THRESHOLD = -115
const val RSSI_FAIR_THRESHOLD = -126
@Stable
-private enum class Quality(
+enum class Quality(
@Stable val nameRes: Int,
@Stable val imageVector: ImageVector,
@Stable val color: @Composable () -> Color,
@@ -79,18 +77,20 @@ private enum class Quality(
@Composable
fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
val quality = determineSignalQuality(snr, rssi)
- FlowRow(modifier = modifier, maxLines = 1) {
+ FlowRow(
+ modifier = modifier,
+ itemVerticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
Snr(snr)
- Spacer(Modifier.width(8.dp))
Rssi(rssi)
- Spacer(Modifier.width(8.dp))
Text(
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
- Spacer(Modifier.width(8.dp))
Icon(
+ modifier = Modifier.size(20.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color.invoke(),
@@ -111,23 +111,26 @@ fun SnrAndRssi(snr: Float, rssi: Int) {
@Composable
fun LoraSignalIndicator(snr: Float, rssi: Int) {
val quality = determineSignalQuality(snr, rssi)
-
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(8.dp),
) {
Icon(
+ modifier = Modifier.size(20.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color.invoke(),
)
- Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}")
+ Text(
+ text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
+ style = MaterialTheme.typography.labelSmall,
+ )
}
}
@Composable
-fun Snr(snr: Float, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
+fun Snr(snr: Float) {
val color: Color =
if (snr > SNR_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
@@ -137,11 +140,15 @@ fun Snr(snr: Float, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fon
Quality.BAD.color.invoke()
}
- Text(text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr), color = color, fontSize = fontSize)
+ Text(
+ text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr),
+ color = color,
+ style = MaterialTheme.typography.labelSmall,
+ )
}
@Composable
-fun Rssi(rssi: Int, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
+fun Rssi(rssi: Int) {
val color: Color =
if (rssi > RSSI_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
@@ -150,10 +157,14 @@ fun Rssi(rssi: Int, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fon
} else {
Quality.BAD.color.invoke()
}
- Text(text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi), color = color, fontSize = fontSize)
+ Text(
+ text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi),
+ color = color,
+ style = MaterialTheme.typography.labelSmall,
+ )
}
-private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
+fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
index e07084358..b43ece092 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
@@ -32,10 +32,12 @@ 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.res.stringResource
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 org.meshtastic.core.strings.R
import org.meshtastic.core.ui.icon.BatteryEmpty
import org.meshtastic.core.ui.icon.BatteryUnknown
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -47,9 +49,9 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
private const val FORMAT = "%d%%"
private const val SIZE_ICON = 20
-@Suppress("MagicNumber")
+@Suppress("MagicNumber", "LongMethod")
@Composable
-fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int) {
+fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int?, voltage: Float? = null) {
val levelString = FORMAT.format(level)
Row(
@@ -57,21 +59,25 @@ fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int) {
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) {
+ if (level == null || level < 0) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp),
imageVector = MeshtasticIcons.BatteryUnknown,
tint = MaterialTheme.colorScheme.onSurface,
- contentDescription = null,
+ contentDescription = stringResource(R.string.unknown),
+ )
+ } else if (level > 100) {
+ Icon(
+ modifier = Modifier.size(SIZE_ICON.dp).rotate(90f),
+ imageVector = Icons.Rounded.Power,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = levelString,
+ )
+
+ Text(
+ text = "PWD",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelMedium,
)
} else {
// Map battery percentage to color
@@ -103,24 +109,42 @@ fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int) {
},
imageVector = MeshtasticIcons.BatteryEmpty,
tint = MaterialTheme.colorScheme.onSurface,
- contentDescription = null,
+ contentDescription = levelString,
)
Text(
text = levelString,
color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.labelLarge,
+ style = MaterialTheme.typography.labelMedium,
)
+ voltage?.let {
+ Text(
+ text = "%.2fV".format(it),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
}
}
}
-class BatteryLevelProvider : PreviewParameterProvider {
- override val values: Sequence = sequenceOf(-1, 19, 39, 90, 101)
+class BatteryInfoPreviewParameterProvider : PreviewParameterProvider> {
+ override val values: Sequence>
+ get() =
+ sequenceOf(
+ 85 to 3.7F,
+ 2 to 3.7F,
+ 12 to 3.7F,
+ 28 to 3.7F,
+ 50 to 3.7F,
+ 101 to 4.9F,
+ null to 4.5F,
+ null to null,
+ )
}
@PreviewLightDark
@Composable
-fun MaterialBatteryInfoPreview(@PreviewParameter(BatteryLevelProvider::class) batteryLevel: Int) {
- AppTheme { MaterialBatteryInfo(level = batteryLevel) }
+fun MaterialBatteryInfoPreview(@PreviewParameter(BatteryInfoPreviewParameterProvider::class) info: Pair) {
+ AppTheme { MaterialBatteryInfo(level = info.first, voltage = info.second) }
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Elevation.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Elevation.kt
new file mode 100644
index 000000000..d77914cd9
--- /dev/null
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Elevation.kt
@@ -0,0 +1,100 @@
+/*
+ * 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 org.meshtastic.core.ui.icon
+
+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
+ * [elevation](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:elevation:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=elevation&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded)
+ */
+val MeshtasticIcons.Elevation: ImageVector
+ get() {
+ if (elevation != null) {
+ return elevation!!
+ }
+ elevation =
+ ImageVector.Builder(
+ name = "Rounded.Elevation",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 960f,
+ viewportHeight = 960f,
+ )
+ .apply {
+ path(fill = SolidColor(Color(0xFFE3E3E3))) {
+ moveTo(760f, 840f)
+ lineTo(160f, 840f)
+ quadToRelative(-25f, 0f, -35.5f, -21.5f)
+ reflectiveQuadTo(128f, 777f)
+ lineToRelative(188f, -264f)
+ quadToRelative(11f, -16f, 28f, -24.5f)
+ reflectiveQuadToRelative(37f, -8.5f)
+ horizontalLineToRelative(161f)
+ lineToRelative(228f, -266f)
+ quadToRelative(18f, -21f, 44f, -11.5f)
+ reflectiveQuadToRelative(26f, 37.5f)
+ verticalLineToRelative(520f)
+ quadToRelative(0f, 33f, -23.5f, 56.5f)
+ reflectiveQuadTo(760f, 840f)
+ close()
+ moveTo(300f, 400f)
+ lineTo(176f, 575f)
+ quadToRelative(-10f, 14f, -26f, 16.5f)
+ reflectiveQuadToRelative(-30f, -7.5f)
+ quadToRelative(-14f, -10f, -16.5f, -26f)
+ reflectiveQuadToRelative(7.5f, -30f)
+ lineToRelative(125f, -174f)
+ quadToRelative(11f, -16f, 28f, -25f)
+ reflectiveQuadToRelative(37f, -9f)
+ horizontalLineToRelative(161f)
+ lineToRelative(162f, -189f)
+ quadToRelative(11f, -13f, 27f, -14f)
+ reflectiveQuadToRelative(29f, 10f)
+ quadToRelative(13f, 11f, 14f, 27f)
+ reflectiveQuadToRelative(-10f, 29f)
+ lineTo(522f, 372f)
+ quadToRelative(-11f, 14f, -27f, 21f)
+ reflectiveQuadToRelative(-33f, 7f)
+ lineTo(300f, 400f)
+ close()
+ moveTo(238f, 760f)
+ horizontalLineToRelative(522f)
+ verticalLineToRelative(-412f)
+ lineTo(602f, 532f)
+ quadToRelative(-11f, 14f, -27f, 21f)
+ reflectiveQuadToRelative(-33f, 7f)
+ lineTo(380f, 560f)
+ lineTo(238f, 760f)
+ close()
+ moveTo(760f, 760f)
+ close()
+ }
+ }
+ .build()
+
+ return elevation!!
+ }
+
+private var elevation: ImageVector? = null
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ef8736cb7..4619399b6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -86,6 +86,7 @@ androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" }
androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-testManifest = { module = "androidx.compose.ui:ui-test-manifest" }
+androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
@@ -216,7 +217,7 @@ testing-room = ["room-testing"]
# UI
adaptive = ["androidx-compose-material3-adaptive", "androidx-compose-material3-adaptive-layout", "androidx-compose-material3-adaptive-navigation", "androidx-compose-material3-navigationSuite"]
-ui = ["material", "constraintlayout", "androidx-compose-material3", "androidx-compose-material-iconsExtended", "androidx-compose-ui-tooling-preview", "compose-runtime-livedata"]
+ui = ["material", "constraintlayout", "androidx-compose-material3", "androidx-compose-material-iconsExtended", "androidx-compose-ui-tooling-preview", "compose-runtime-livedata", "androidx-compose-ui-text"]
ui-tooling = ["androidx-compose-ui-tooling"] #Separate for debugImplementation
[plugins]