feat: add high-contrast theme with accessible message bubbles (#5135)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-14 20:14:20 -05:00 committed by GitHub
parent f48fc61729
commit fa63a4ac50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 328 additions and 65 deletions

View file

@ -37,7 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
@ -134,7 +133,7 @@ private fun QuickEmojiRow(quickEmojis: List<String>, onReact: (String) -> Unit,
.clickable { onReact(emoji) },
contentAlignment = Alignment.Center,
) {
Text(text = emoji, fontSize = 20.sp)
Text(text = emoji, style = MaterialTheme.typography.titleMedium)
}
}

View file

@ -29,14 +29,12 @@ 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.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -47,8 +45,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -72,6 +73,8 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.FormatQuote
import org.meshtastic.core.ui.icon.HopCount
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.ContrastLevel
import org.meshtastic.core.ui.theme.LocalContrastLevel
import org.meshtastic.core.ui.theme.MessageItemColors
import org.meshtastic.core.ui.util.createClipEntry
@ -175,7 +178,9 @@ fun MessageItem(
}
val containsBel = message.text.contains('\u0007')
val contrastLevel = LocalContrastLevel.current
val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second)
val alpha =
if (message.filtered) {
FILTERED_ALPHA
@ -184,15 +189,31 @@ fun MessageItem(
} else {
NORMAL_ALPHA
}
val containerColor =
if (message.fromLocal) {
Color(ourNode.colors.second).copy(alpha = alpha)
} else {
Color(node.colors.second).copy(alpha = alpha)
when (contrastLevel) {
ContrastLevel.HIGH ->
when {
message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow
inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest
inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f))
ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha)
}
val contentColor =
when (contrastLevel) {
ContrastLevel.HIGH,
ContrastLevel.MEDIUM,
-> MaterialTheme.colorScheme.onSurface
ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first)
}
val metadataStyle =
when (contrastLevel) {
ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall
else -> MaterialTheme.typography.labelSmall
}
val cardColors =
CardDefaults.cardColors()
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
val messageShape =
getMessageBubbleShape(
cornerRadius = 8.dp,
@ -206,7 +227,12 @@ fun MessageItem(
if (containsBel) {
Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape)
} else {
Modifier
when (contrastLevel) {
ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape)
ContrastLevel.MEDIUM ->
Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape)
ContrastLevel.STANDARD -> Modifier
}
},
)
val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name
@ -244,9 +270,12 @@ fun MessageItem(
onDoubleClick = onDoubleClick,
)
.then(messageModifier)
.semantics(mergeDescendants = true) { contentDescription = messageA11yText },
.semantics(mergeDescendants = true) {
contentDescription = messageA11yText
role = Role.Button
},
color = containerColor,
contentColor = contentColorFor(containerColor),
contentColor = contentColor,
shape = messageShape,
) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
@ -254,16 +283,11 @@ fun MessageItem(
modifier = Modifier.fillMaxWidth(),
message = message,
ourNode = ourNode,
hasSamePrev = hasSamePrev,
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
)
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
AutoLinkText(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = cardColors.contentColor,
)
AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor)
Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
if (!message.fromLocal) {
@ -281,7 +305,10 @@ fun MessageItem(
imageVector = MeshtasticIcons.HopCount,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = cardColors.contentColor.copy(alpha = 0.7f),
tint =
contentColor.copy(
alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f,
),
)
Text(
text =
@ -290,7 +317,7 @@ fun MessageItem(
} else {
"?"
},
style = MaterialTheme.typography.labelSmall,
style = metadataStyle,
)
}
}
@ -306,8 +333,13 @@ fun MessageItem(
if (message.filtered) {
Text(
text = stringResource(Res.string.filter_message_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = metadataStyle,
color =
if (contrastLevel == ContrastLevel.HIGH) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
)
}
@ -318,11 +350,7 @@ fun MessageItem(
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
modifier = Modifier.padding(start = 16.dp),
text = message.time,
style = MaterialTheme.typography.labelSmall,
)
Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle)
}
}
}
@ -356,30 +384,33 @@ private enum class ActiveSheet {
private fun OriginalMessageSnippet(
message: Message,
ourNode: Node,
hasSamePrev: Boolean,
onNavigateToOriginalMessage: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val originalMessage = message.originalMessage
if (originalMessage != null && originalMessage.packetId != 0) {
val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node
val cardColors =
CardDefaults.cardColors()
.copy(
containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f),
contentColor = Color(originalMessageNode.colors.first),
)
val contrastLevel = LocalContrastLevel.current
val replyContainerColor =
when (contrastLevel) {
ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer
else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f)
}
val replyContentColor =
when (contrastLevel) {
ContrastLevel.HIGH,
ContrastLevel.MEDIUM,
-> MaterialTheme.colorScheme.onSurface
ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first)
}
// Rectangle shape — the outer message bubble's Surface clips to its
// rounded corners, so the reply header inherits the correct top radii
// automatically and stays square on the bottom where body text follows.
Surface(
modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
contentColor = cardColors.contentColor,
color = cardColors.containerColor,
shape =
getMessageBubbleShape(
cornerRadius = 16.dp,
isSender = originalMessage.fromLocal,
hasSamePrev = hasSamePrev,
hasSameNext = true, // always square off original message bottom
),
contentColor = replyContentColor,
color = replyContainerColor,
shape = RectangleShape,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),

View file

@ -123,7 +123,6 @@ internal fun ReactionItem(
text = emojiCount.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@ -248,7 +247,13 @@ internal fun ReactionDialog(
text = "$emoji${reactions.size}",
modifier =
Modifier.clip(CircleShape)
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
.background(
if (selectedEmoji == emoji) {
MaterialTheme.colorScheme.surfaceContainerHigh
} else {
Color.Transparent
},
)
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier)
.padding(8.dp)
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },