fix(ui): stable LazyColumn keys, semantic roles, and content descriptions

Two Compose correctness/accessibility fixes from the UI audit:

* feature/messaging Reaction.kt: three items() blocks in LazyRow/
  LazyColumn had no key= parameter, which caused incorrect state and
  animation when the underlying lists reorder. Add stable keys: Map.Entry
  iterations use it.key; the reaction list uses a composite
  packetId:userId:emoji:timestamp because packetId defaults to 0 for
  pending/local reactions.
* Accessibility pass across core/ui and feature/{settings,node,
  wifi-provision}: add role=Role.Button + onClickLabel to clickable
  Box/Column/Row/Text widgets that were rendering as plain containers
  to TalkBack (RegularPreference, IndoorAirQuality, NodeFilterTextField,
  ClickableTextField trailing icon). Add contentDescription (via
  stringResource) to meaningful Close/Filter/PhoneAndroid icons that
  previously passed null. Replace hardcoded English strings in
  contentDescription slots with six new keys in core/resources
  (export_tak_data_package, mpwrd_os translatable=false, clear_time_zone,
  filter_icon, remove_filter, show_iaq_legend).

Roughly 200 insertions across 10 files; no behavior change other than
screen-reader output and stable list-item identity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-17 10:46:48 -05:00
parent df3b5365f9
commit 387acd7a2f
9 changed files with 79 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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