diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index d86df9d60..8316ad8e2 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -124,6 +124,8 @@ class MainActivity : ComponentActivity() { setSingletonImageLoaderFactory { get() } val theme by model.theme.collectAsStateWithLifecycle() + val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle() + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) val dynamic = theme == MODE_DYNAMIC val dark = when (theme) { @@ -141,7 +143,7 @@ class MainActivity : ComponentActivity() { } AppCompositionLocals { - AppTheme(dynamicColor = dynamic, darkTheme = dark) { + AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() // Signal to the system that the initial UI is "fully drawn" diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt new file mode 100644 index 000000000..fa708d165 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.domain.usecase.settings + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.UiPrefs + +@Single +open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) { + operator fun invoke(value: Int) { + uiPrefs.setContrastLevel(value) + } +} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 33f688389..7fe0da822 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -62,6 +62,13 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_THEME] = value } } } + override val contrastLevel: StateFlow = + dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0) + + override fun setContrastLevel(value: Int) { + scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } } + } + override val locale: StateFlow = dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "") @@ -152,6 +159,7 @@ class UiPrefsImpl( val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed") val KEY_THEME = intPreferencesKey("theme") + val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level") val KEY_LOCALE = stringPreferencesKey("locale") val KEY_NODE_SORT = intPreferencesKey("node-sort-option") val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown") diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index f5203e3c1..bb32c1fbd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -80,6 +80,10 @@ interface UiPrefs { fun setTheme(value: Int) + val contrastLevel: StateFlow + + fun setContrastLevel(value: Int) + val locale: StateFlow fun setLocale(languageTag: String) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9678c9919..77c923d94 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -278,10 +278,15 @@ Reset to defaults Apply Theme + Contrast Light Dark System default Choose theme + Contrast level + Standard + Medium + High Provide phone location to mesh Compact encoding for Cyrillic diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 2b9f9918f..9a703004c 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -84,6 +84,12 @@ class FakeUiPrefs : UiPrefs { theme.value = value } + override val contrastLevel = MutableStateFlow(0) + + override fun setContrastLevel(value: Int) { + contrastLevel.value = value + } + override val locale = MutableStateFlow("en") override fun setLocale(languageTag: String) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt new file mode 100644 index 000000000..cd68cd12c --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Application-wide contrast level for accessibility. + * + * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and + * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in + * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders. + */ +enum class ContrastLevel(val value: Int) { + STANDARD(0), + MEDIUM(1), + HIGH(2), + ; + + companion object { + fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD + } +} + +/** + * Composition local providing the current [ContrastLevel]. + * + * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators). + */ +val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt index eb40222af..07c6ab3ad 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("UnusedPrivateProperty") +@file:Suppress("MatchingDeclarationName") package org.meshtastic.core.ui.theme @@ -25,6 +25,7 @@ import androidx.compose.material3.MotionScheme.Companion.expressive import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -272,19 +273,33 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + contrastLevel: ContrastLevel = ContrastLevel.STANDARD, content: @Composable() () -> Unit, ) { - val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null - val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme + val dynamicScheme = + if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) { + dynamicColorScheme(darkTheme) + } else { + null + } + val colorScheme = + dynamicScheme + ?: when (contrastLevel) { + ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + else -> if (darkTheme) darkScheme else lightScheme + } - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = AppTypography, - motionScheme = expressive(), - content = content, - ) + CompositionLocalProvider(LocalContrastLevel provides contrastLevel) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = AppTypography, + motionScheme = expressive(), + content = content, + ) + } } const val MODE_DYNAMIC = 6969420 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index b1c4cebf2..12f1ea0f5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -118,6 +118,7 @@ class UIViewModel( } val theme: StateFlow = uiPrefs.theme + val contrastLevel: StateFlow = uiPrefs.contrastLevel val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 8b33a3612..11111dd7a 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -169,7 +169,8 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { val uiPrefs = koinInject() val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - + val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0) + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = @@ -179,7 +180,7 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { else -> isSystemInDarkTheme() } - MeshtasticDesktopApp(uiViewModel, isDarkTheme) + MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel) } // ----- Application chrome (tray, window, navigation) ----- @@ -187,7 +188,11 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { /** Composes the system tray, window, and Coil image loader. */ @Composable @OptIn(ExperimentalCoilApi::class) -private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) { +private fun ApplicationScope.MeshtasticDesktopApp( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, +) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() @@ -219,7 +224,7 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa ) if (isWindowReady && isAppVisible) { - MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false } + MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false } } } @@ -267,6 +272,7 @@ private fun WindowBoundsManager( private fun ApplicationScope.MeshtasticWindow( uiViewModel: UIViewModel, isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, appIcon: Painter, windowState: WindowState, onCloseRequest: () -> Unit, @@ -281,7 +287,9 @@ private fun ApplicationScope.MeshtasticWindow( onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, ) { CoilImageLoaderSetup() - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } + AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) { + DesktopMainScreen(uiViewModel, multiBackstack) + } } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index 380b913a5..c4c99720c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -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, onReact: (String) -> Unit, .clickable { onReact(emoji) }, contentAlignment = Alignment.Center, ) { - Text(text = emoji, fontSize = 20.sp) + Text(text = emoji, style = MaterialTheme.typography.titleMedium) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 586b91dd6..7d8747eb8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -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), 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 6545083bb..27797592b 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 @@ -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 }, diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 82558309d..eeab3b873 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -56,6 +56,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection @@ -155,6 +156,14 @@ fun SettingsScreen( ) } + var showContrastPickerDialog by remember { mutableStateOf(false) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + Scaffold( topBar = { MainAppBar( @@ -227,6 +236,7 @@ fun SettingsScreen( AppearanceSection( onShowLanguagePicker = { showLanguagePickerDialog = true }, onShowThemePicker = { showThemePickerDialog = true }, + onShowContrastPicker = { showContrastPickerDialog = true }, ) ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt index f70cda978..cb61c8295 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt @@ -28,6 +28,7 @@ import androidx.core.net.toUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.theme import org.meshtastic.core.ui.component.ListItem @@ -37,9 +38,13 @@ import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme -/** Section for app appearance settings like language and theme. */ +/** Section for app appearance settings like language, theme, and contrast. */ @Composable -fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) { +fun AppearanceSection( + onShowLanguagePicker: () -> Unit, + onShowThemePicker: () -> Unit, + onShowContrastPicker: () -> Unit, +) { val context = LocalContext.current val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -74,11 +79,19 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> ) { onShowThemePicker() } + + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + onShowContrastPicker() + } } } @Preview(showBackground = true) @Composable private fun AppearanceSectionPreview() { - AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) } + AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index fc5923c1a..d4b39565b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -65,6 +66,7 @@ class SettingsViewModel( private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, private val setThemeUseCase: SetThemeUseCase, + private val setContrastLevelUseCase: SetContrastLevelUseCase, private val setLocaleUseCase: SetLocaleUseCase, private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, private val setProvideLocationUseCase: SetProvideLocationUseCase, @@ -162,6 +164,10 @@ class SettingsViewModel( setThemeUseCase(theme) } + fun setContrastLevel(level: Int) { + setContrastLevelUseCase(level) + } + /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { setLocaleUseCase(languageTag) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt new file mode 100644 index 000000000..c8adc418a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MatchingDeclarationName") + +package org.meshtastic.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.choose_contrast +import org.meshtastic.core.resources.contrast_high +import org.meshtastic.core.resources.contrast_medium +import org.meshtastic.core.resources.contrast_standard +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.theme.ContrastLevel + +/** Contrast level options matching [ContrastLevel] ordinal values. */ +enum class ContrastOption(val label: StringResource, val level: ContrastLevel) { + STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD), + MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM), + HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH), +} + +/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */ +@Composable +fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) { + MeshtasticDialog( + title = stringResource(Res.string.choose_contrast), + onDismiss = onDismiss, + text = { + Column { + ContrastOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickContrast(option.level.value) + onDismiss() + } + } + } + }, + ) +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 64eab2f80..0ba5c3a79 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -96,6 +97,7 @@ class SettingsViewModelTest { val uiPrefs = appPreferences.ui val setThemeUseCase = SetThemeUseCase(uiPrefs) + val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs) val setLocaleUseCase = SetLocaleUseCase(uiPrefs) val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) @@ -116,6 +118,7 @@ class SettingsViewModelTest { meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, setThemeUseCase = setThemeUseCase, + setContrastLevelUseCase = setContrastLevelUseCase, setLocaleUseCase = setLocaleUseCase, setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, setProvideLocationUseCase = setProvideLocationUseCase, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 9a221f8dd..2e358a58c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary import org.meshtastic.core.resources.info @@ -67,6 +68,7 @@ import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection @@ -101,6 +103,7 @@ fun DesktopSettingsScreen( var showThemePickerDialog by remember { mutableStateOf(false) } var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showContrastPickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -108,6 +111,13 @@ fun DesktopSettingsScreen( ) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + if (showLanguagePickerDialog) { LanguagePickerDialog( onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) }, @@ -172,6 +182,14 @@ fun DesktopSettingsScreen( showThemePickerDialog = true } + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + showContrastPickerDialog = true + } + ListItem( text = stringResource(Res.string.preferences_language), leadingIcon = MeshtasticIcons.Language,