From 90f6e21a9c5529a25f4ee980bafec364f4bea45f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 17 Apr 2026 11:24:18 -0500
Subject: [PATCH] fix(ui): stable LazyColumn keys, semantic roles, and content
descriptions (#5168)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 6 +++
.../core/ui/component/ClickableTextField.kt | 3 +-
.../core/ui/component/IndoorAirQuality.kt | 44 ++++++++++++++++---
.../core/ui/component/RegularPreference.kt | 9 +++-
.../feature/messaging/component/Reaction.kt | 6 +--
.../node/component/NodeFilterTextField.kt | 15 +++++--
.../settings/debugging/DebugFilters.kt | 16 ++++++-
.../radio/component/DeviceConfigScreen.kt | 11 ++++-
.../radio/component/TAKConfigItemList.kt | 6 ++-
.../wifiprovision/ui/WifiProvisionScreen.kt | 3 +-
10 files changed, 99 insertions(+), 20 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 9bd1b68de..87268ecda 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1270,4 +1270,10 @@
Show Meshtastic
Quit
Meshtastic
+ Export TAK Data Package
+ mPWRD-OS
+ Clear time zone
+ Filter
+ Remove filter
+ Show air quality legend
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt
index 7330c1aa6..125e1e117 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt
@@ -38,6 +38,7 @@ fun ClickableTextField(
onClick: () -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false,
+ trailingIconContentDescription: String? = null,
) {
val source = remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
@@ -49,7 +50,7 @@ fun ClickableTextField(
enabled = enabled,
readOnly = true,
label = { Text(stringResource(label)) },
- trailingIcon = { Icon(trailingIcon, null) },
+ trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) },
isError = isError,
interactionSource = source,
modifier = modifier,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
index b84c11e13..2fa66b468 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -58,6 +59,7 @@ import org.meshtastic.core.resources.preview_gauge
import org.meshtastic.core.resources.preview_gradient
import org.meshtastic.core.resources.preview_pill
import org.meshtastic.core.resources.preview_text
+import org.meshtastic.core.resources.show_iaq_legend
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.ThumbUp
import org.meshtastic.core.ui.icon.Warning
@@ -120,13 +122,18 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
Column {
when (displayMode) {
IaqDisplayMode.Pill -> {
+ val legendLabel = stringResource(Res.string.show_iaq_legend)
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(iaqEnum.color)
.width(125.dp)
.height(30.dp)
- .clickable { isLegendOpen = true },
+ .clickable(
+ onClickLabel = legendLabel,
+ role = Role.Button,
+ onClick = { isLegendOpen = true },
+ ),
) {
Row(
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
@@ -144,7 +151,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
IaqDisplayMode.Dot -> {
- Column(modifier = Modifier.clickable { isLegendOpen = true }) {
+ val legendLabel = stringResource(Res.string.show_iaq_legend)
+ Column(
+ modifier =
+ Modifier.clickable(
+ onClickLabel = legendLabel,
+ role = Role.Button,
+ onClick = { isLegendOpen = true },
+ ),
+ ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$iaq")
Spacer(modifier = Modifier.width(4.dp))
@@ -154,17 +169,30 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
IaqDisplayMode.Text -> {
+ val legendLabel = stringResource(Res.string.show_iaq_legend)
Text(
text = getIaqDescriptionWithRange(iaqEnum),
fontSize = 12.sp,
- modifier = Modifier.clickable { isLegendOpen = true },
+ modifier =
+ Modifier.clickable(
+ onClickLabel = legendLabel,
+ role = Role.Button,
+ onClick = { isLegendOpen = true },
+ ),
)
}
IaqDisplayMode.Gauge -> {
+ val legendLabel = stringResource(Res.string.show_iaq_legend)
CircularProgressIndicator(
progress = { iaq / 500f },
- modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
+ modifier =
+ Modifier.size(60.dp)
+ .clickable(
+ onClickLabel = legendLabel,
+ role = Role.Button,
+ onClick = { isLegendOpen = true },
+ ),
strokeWidth = 8.dp,
color = iaqEnum.color,
)
@@ -172,9 +200,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
IaqDisplayMode.Gradient -> {
+ val legendLabel = stringResource(Res.string.show_iaq_legend)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.clickable { isLegendOpen = true },
+ modifier =
+ Modifier.clickable(
+ onClickLabel = legendLabel,
+ role = Role.Button,
+ onClick = { isLegendOpen = true },
+ ),
) {
LinearProgressIndicator(
progress = { iaq / 500f },
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt
index afa82460d..f9f839ea5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt
@@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -80,7 +81,13 @@ fun RegularPreference(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
- Column(modifier = modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(all = 16.dp)) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clickable(enabled = enabled, onClick = onClick, role = Role.Button)
+ .padding(all = 16.dp),
+ ) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
index 27797592b..9b8267793 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
@@ -143,7 +143,7 @@ internal fun ReactionRow(
AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) {
LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- items(emojiGroups.entries.toList()) { entry ->
+ items(emojiGroups.entries.toList(), key = { it.key }) { entry ->
val emoji = entry.key
val reactions = entry.value
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
@@ -237,7 +237,7 @@ internal fun ReactionDialog(
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
- items(groupedEmojis.entries.toList()) { entry ->
+ items(groupedEmojis.entries.toList(), key = { it.key }) { entry ->
val emoji = entry.key
val reactions = entry.value
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
@@ -265,7 +265,7 @@ internal fun ReactionDialog(
HorizontalDivider(Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
- items(filteredReactions) { reaction ->
+ items(filteredReactions, key = { reaction -> "${reaction.user.id}:${reaction.emoji}" }) { reaction ->
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt
index cfac18158..0bc022c34 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt
@@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
@@ -56,6 +57,7 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.desc_node_filter_clear
import org.meshtastic.core.resources.node_filter_exclude_infrastructure
import org.meshtastic.core.resources.node_filter_exclude_mqtt
@@ -178,14 +180,19 @@ private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Un
onValueChange = onTextChange,
trailingIcon = {
if (filterText.isNotEmpty() || isFocused) {
+ val clearLabel = stringResource(Res.string.clear)
Icon(
MeshtasticIcons.Close,
contentDescription = stringResource(Res.string.desc_node_filter_clear),
modifier =
- Modifier.clickable {
- onTextChange("")
- focusManager.clearFocus()
- },
+ Modifier.clickable(
+ onClickLabel = clearLabel,
+ role = Role.Button,
+ onClick = {
+ onTextChange("")
+ focusManager.clearFocus()
+ },
+ ),
)
}
},
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
index 37cdeab71..df4a0965f 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
@@ -57,8 +57,10 @@ import org.meshtastic.core.resources.debug_filter_clear
import org.meshtastic.core.resources.debug_filter_included
import org.meshtastic.core.resources.debug_filter_preset_title
import org.meshtastic.core.resources.debug_filters
+import org.meshtastic.core.resources.filter_icon
import org.meshtastic.core.resources.match_all
import org.meshtastic.core.resources.match_any
+import org.meshtastic.core.resources.remove_filter
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Close
@@ -281,8 +283,18 @@ fun DebugActiveFilters(
selected = true,
onClick = { onFilterTextsChange(filterTexts - filter) },
label = { Text(filter) },
- leadingIcon = { Icon(imageVector = MeshtasticIcons.FilterAlt, contentDescription = null) },
- trailingIcon = { Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) },
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.FilterAlt,
+ contentDescription = stringResource(Res.string.filter_icon),
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.Close,
+ contentDescription = stringResource(Res.string.remove_filter),
+ )
+ },
)
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
index c65cd971b..a614c1f99 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
@@ -59,6 +59,7 @@ import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.button_gpio
import org.meshtastic.core.resources.buzzer_gpio
import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.clear_time_zone
import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary
import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary
import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary
@@ -269,7 +270,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
onValueChanged = { formState.value = formState.value.copy(tzdef = it) },
trailingIcon = {
IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) {
- Icon(imageVector = MeshtasticIcons.Close, contentDescription = null)
+ Icon(
+ imageVector = MeshtasticIcons.Close,
+ contentDescription = stringResource(Res.string.clear_time_zone),
+ )
}
},
)
@@ -282,7 +286,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
shape = RectangleShape,
onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) },
) {
- Icon(imageVector = MeshtasticIcons.PhoneAndroid, contentDescription = null)
+ Icon(
+ imageVector = MeshtasticIcons.PhoneAndroid,
+ contentDescription = stringResource(Res.string.config_device_use_phone_tz),
+ )
Spacer(modifier = Modifier.width(8.dp))
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index 0e3c9058d..526bd63ef 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -30,6 +30,7 @@ import org.meshtastic.core.model.getColorFrom
import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.export_tak_data_package
import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.tak_config
import org.meshtastic.core.resources.tak_role
@@ -74,7 +75,10 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
onBack = onBack,
actions = {
IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) {
- Icon(imageVector = MeshtasticIcons.Share, contentDescription = "Export TAK Data Package")
+ Icon(
+ imageVector = MeshtasticIcons.Share,
+ contentDescription = stringResource(Res.string.export_tak_data_package),
+ )
}
},
configState = formState,
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
index 785654c71..015a4e08b 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
@@ -92,6 +92,7 @@ import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_password
import org.meshtastic.core.resources.img_mpwrd_logo
+import org.meshtastic.core.resources.mpwrd_os
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.show_password
import org.meshtastic.core.resources.wifi_provision_available_networks
@@ -513,7 +514,7 @@ internal fun MpwrdDisclaimerBanner() {
) {
Image(
painter = painterResource(Res.drawable.img_mpwrd_logo),
- contentDescription = "mPWRD-OS",
+ contentDescription = stringResource(Res.string.mpwrd_os),
modifier = Modifier.size(MPWRD_LOGO_SIZE_DP.dp).clip(RoundedCornerShape(8.dp)),
)
AutoLinkText(