From 5d198c7407c2e47cbdd64c16bcb83684e25f92e3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:26:28 -0600 Subject: [PATCH] feat(nodes): Display role-specific icons (#4572) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../core/ui/component/DropDownPreference.kt | 104 +++++++++++++++--- .../core/ui/component/TelemetryInfo.kt | 18 +++ .../org/meshtastic/core/ui/icon/Device.kt | 29 +++++ .../main/res/drawable/mountain_flag_24px.xml | 5 + .../node/component/NodeDetailsSection.kt | 4 +- .../feature/node/component/NodeItem.kt | 2 +- .../radio/component/DeviceConfigItemList.kt | 4 + 7 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 core/ui/src/main/res/drawable/mountain_flag_24px.xml diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index ad51ecd2e..f6b5e6e64 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -17,13 +17,18 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -31,7 +36,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +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 @@ -43,13 +50,26 @@ fun > DropDownPreference( onItemSelected: (T) -> Unit, modifier: Modifier = Modifier, summary: String? = null, + itemIcon: @Composable ((T) -> ImageVector)? = null, + itemLabel: @Composable ((T) -> String)? = null, ) { + val enumConstants = + remember(selectedItem) { + selectedItem.declaringJavaClass.enumConstants?.filter { it.name != "UNRECOGNIZED" && !it.isDeprecated() } + ?: emptyList() + } + + val items = + enumConstants.map { + val label = itemLabel?.invoke(it) ?: it.name + val icon = itemIcon?.invoke(it) + DropDownItem(it, label, icon) + } + DropDownPreference( title = title, enabled = enabled, - items = - selectedItem.declaringJavaClass.enumConstants?.filter { it.name != "UNRECOGNIZED" }?.map { it to it.name } - ?: emptyList(), + items = items, selectedItem = selectedItem, onItemSelected = onItemSelected, modifier = modifier, @@ -57,7 +77,9 @@ fun > DropDownPreference( ) } -@OptIn(ExperimentalMaterial3Api::class) +data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null) + +@JvmName("DropDownPreferencePairs") @Composable fun DropDownPreference( title: String, @@ -67,10 +89,32 @@ fun DropDownPreference( onItemSelected: (T) -> Unit, modifier: Modifier = Modifier, summary: String? = null, +) { + DropDownPreference( + title = title, + enabled = enabled, + items = items.map { DropDownItem(it.first, it.second) }, + selectedItem = selectedItem, + onItemSelected = onItemSelected, + modifier = modifier, + summary = summary, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +fun DropDownPreference( + title: String, + enabled: Boolean, + items: List>, + selectedItem: T, + onItemSelected: (T) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, ) { var expanded by remember { mutableStateOf(false) } - val deprecatedItems: List = emptyList() // Protobuf-Java specific deprecation check removed Column(modifier = modifier.fillMaxWidth().padding(8.dp)) { ExposedDropdownMenuBox( expanded = expanded, @@ -80,13 +124,24 @@ fun DropDownPreference( } }, ) { + val currentItem = items.firstOrNull { it.value == selectedItem } OutlinedTextField( label = { Text(text = title) }, modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), readOnly = true, - value = items.firstOrNull { it.first == selectedItem }?.second ?: "", + value = currentItem?.label ?: "", onValueChange = {}, + leadingIcon = + currentItem?.icon?.let { + { + Icon( + imageVector = it, + contentDescription = currentItem.label, + modifier = Modifier.size(24.dp), + ) + } + }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.textFieldColors(), enabled = enabled, @@ -98,22 +153,35 @@ fun DropDownPreference( }, ) ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - items - .filterNot { it.first in deprecatedItems } - .forEach { selectionOption -> - DropdownMenuItem( - text = { Text(selectionOption.second) }, - onClick = { - onItemSelected(selectionOption.first) - expanded = false - }, - ) - } + items.forEach { selectionOption -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + selectionOption.icon?.let { + Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + } + Text(selectionOption.label) + } + }, + onClick = { + onItemSelected(selectionOption.value) + expanded = false + }, + ) + } } } } } +private fun Enum<*>.isDeprecated(): Boolean = try { + val field = this::class.java.getField(this.name) + field.isAnnotationPresent(Deprecated::class.java) || field.isAnnotationPresent(java.lang.Deprecated::class.java) +} catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) { + false +} + @Preview(showBackground = true) @Composable private fun DropDownPreferencePreview() { @@ -121,7 +189,7 @@ private fun DropDownPreferencePreview() { title = "Settings", summary = "Lorem ipsum dolor sit amet", enabled = true, - items = listOf("TEST1" to "text1", "TEST2" to "text2"), + items = listOf(DropDownItem("TEST1", "text1"), DropDownItem("TEST2", "text2")), selectedItem = "TEST2", onItemSelected = {}, ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt index dfca24d24..051689af6 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TelemetryInfo.kt @@ -64,6 +64,8 @@ import org.meshtastic.core.ui.icon.Pressure import org.meshtastic.core.ui.icon.Role import org.meshtastic.core.ui.icon.Soil import org.meshtastic.core.ui.icon.Temperature +import org.meshtastic.core.ui.icon.role +import org.meshtastic.proto.Config private const val SIZE_ICON = 14 @@ -230,6 +232,22 @@ fun HardwareInfo( ) } +@Composable +fun RoleInfo( + role: Config.DeviceConfig.Role, + modifier: Modifier = Modifier, + contentColor: Color = MaterialTheme.colorScheme.onSurface, +) { + IconInfo( + modifier = modifier, + icon = MeshtasticIcons.role(role), + contentDescription = stringResource(Res.string.role), + text = role.name, + style = MaterialTheme.typography.labelSmall, + contentColor = contentColor, + ) +} + @Composable fun RoleInfo(role: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { IconInfo( diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Device.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Device.kt index 079a920f2..5c368b8fe 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Device.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Device.kt @@ -18,13 +18,25 @@ package org.meshtastic.core.ui.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Fingerprint +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.MilitaryTech +import androidx.compose.material.icons.rounded.MyLocation +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonOff import androidx.compose.material.icons.rounded.Router +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Sensors +import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.Work +import androidx.compose.runtime.Composable 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.res.vectorResource import androidx.compose.ui.unit.dp +import org.meshtastic.core.ui.R +import org.meshtastic.proto.Config val MeshtasticIcons.HardwareModel: ImageVector get() = Icons.Rounded.Router @@ -33,6 +45,23 @@ val MeshtasticIcons.Role: ImageVector val MeshtasticIcons.NodeId: ImageVector get() = Icons.Rounded.Fingerprint +/** Returns a specific icon for a given [Config.DeviceConfig.Role]. */ +@Composable +fun MeshtasticIcons.role(role: Config.DeviceConfig.Role?): ImageVector = when (role) { + Config.DeviceConfig.Role.CLIENT -> Icons.Rounded.Person + Config.DeviceConfig.Role.CLIENT_MUTE -> Icons.Rounded.PersonOff + Config.DeviceConfig.Role.ROUTER -> ImageVector.vectorResource(R.drawable.mountain_flag_24px) + Config.DeviceConfig.Role.TRACKER -> Icons.Rounded.MyLocation + Config.DeviceConfig.Role.SENSOR -> Icons.Rounded.Sensors + Config.DeviceConfig.Role.TAK -> Icons.Rounded.MilitaryTech + Config.DeviceConfig.Role.TAK_TRACKER -> Icons.Rounded.MyLocation + Config.DeviceConfig.Role.CLIENT_HIDDEN -> Icons.Rounded.VisibilityOff + Config.DeviceConfig.Role.LOST_AND_FOUND -> Icons.Rounded.Search + Config.DeviceConfig.Role.CLIENT_BASE -> Icons.Rounded.Home + Config.DeviceConfig.Role.ROUTER_LATE -> Icons.Rounded.Router + else -> Icons.Rounded.Work +} + /** * This is from Material Symbols. * diff --git a/core/ui/src/main/res/drawable/mountain_flag_24px.xml b/core/ui/src/main/res/drawable/mountain_flag_24px.xml new file mode 100644 index 000000000..d0c7a8496 --- /dev/null +++ b/core/ui/src/main/res/drawable/mountain_flag_24px.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 13183336f..e84892134 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -88,8 +88,8 @@ import org.meshtastic.core.ui.icon.KeyOff import org.meshtastic.core.ui.icon.Lock import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Person -import org.meshtastic.core.ui.icon.Role import org.meshtastic.core.ui.icon.Verified +import org.meshtastic.core.ui.icon.role import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.formatAgo @@ -180,7 +180,7 @@ private fun NameAndRoleRow(node: Node) { InfoItem( label = stringResource(Res.string.role), value = node.user.role?.name ?: "", - icon = MeshtasticIcons.Role, + icon = MeshtasticIcons.role(node.user.role), modifier = Modifier.weight(1f), ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 1eabb0145..31f5bddc1 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -458,7 +458,7 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) { verticalAlignment = Alignment.CenterVertically, ) { HardwareInfo(hwModel = thatNode.user.hw_model.name, contentColor = contentColor) - RoleInfo(role = thatNode.user.role.name, contentColor = contentColor) + RoleInfo(role = thatNode.user.role, contentColor = contentColor) NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt index 29ecb1f53..2d43ba372 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt @@ -117,6 +117,8 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.InsetDivider import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.role import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString @@ -195,6 +197,8 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack selectedItem = currentRole, onItemSelected = { selectedRole = it }, summary = stringResource(currentRole.description), + itemIcon = { MeshtasticIcons.role(it) }, + itemLabel = { it.name }, ) HorizontalDivider()