feat(nodes): Display role-specific icons (#4572)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-16 18:26:28 -06:00 committed by GitHub
parent 8c5bc65334
commit 5d198c7407
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 145 additions and 21 deletions

View file

@ -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 <T : Enum<T>> 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 <T : Enum<T>> DropDownPreference(
)
}
@OptIn(ExperimentalMaterial3Api::class)
data class DropDownItem<T>(val value: T, val label: String, val icon: ImageVector? = null)
@JvmName("DropDownPreferencePairs")
@Composable
fun <T> DropDownPreference(
title: String,
@ -67,10 +89,32 @@ fun <T> 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 <T> DropDownPreference(
title: String,
enabled: Boolean,
items: List<DropDownItem<T>>,
selectedItem: T,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
val deprecatedItems: List<T> = emptyList() // Protobuf-Java specific deprecation check removed
Column(modifier = modifier.fillMaxWidth().padding(8.dp)) {
ExposedDropdownMenuBox(
expanded = expanded,
@ -80,13 +124,24 @@ fun <T> 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 <T> 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 = {},
)

View file

@ -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(

View file

@ -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.
*

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M348,517L403,554L462,524Q470,519 480,519Q490,519 498,524L557,554L610,519L570,440Q570,440 570,440Q570,440 570,440L386,440Q386,440 386,440Q386,440 386,440L348,517ZM209,800L750,800L646,591L582,634Q573,640 562.5,640.5Q552,641 542,636L480,605L418,636Q408,641 397.5,640Q387,639 378,633L312,590L209,800ZM144,880Q121,880 109.5,861Q98,842 108,822L314,405Q324,385 343.5,372.5Q363,360 386,360L440,360L440,120Q440,103 451.5,91.5Q463,80 480,80L688,80Q699,80 705,89.5Q711,99 706,109L684,151Q682,156 682,160Q682,164 684,169L706,211Q711,221 705,230.5Q699,240 688,240L520,240L520,360L570,360Q593,360 612,372Q631,384 642,404L851,822Q861,842 849.5,861Q838,880 815,880L144,880ZM480,605L480,605L480,605L480,605L480,605L480,605L480,605Q480,605 480,605Q480,605 480,605L480,605Q480,605 480,605Q480,605 480,605L480,605L480,605L480,605L480,605Z"/>
</vector>

View file

@ -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),
)
}

View file

@ -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)
}
}

View file

@ -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()