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
19 changed files with 328 additions and 65 deletions

View File

@@ -124,6 +124,8 @@ class MainActivity : ComponentActivity() {
setSingletonImageLoaderFactory { get<ImageLoader>() }
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"

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -62,6 +62,13 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_THEME] = value } }
}
override val contrastLevel: StateFlow<Int> =
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<String> =
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")

View File

@@ -80,6 +80,10 @@ interface UiPrefs {
fun setTheme(value: Int)
val contrastLevel: StateFlow<Int>
fun setContrastLevel(value: Int)
val locale: StateFlow<String>
fun setLocale(languageTag: String)

View File

@@ -278,10 +278,15 @@
<string name="reset_to_defaults">Reset to defaults</string>
<string name="apply">Apply</string>
<string name="theme">Theme</string>
<string name="contrast">Contrast</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">System default</string>
<string name="choose_theme">Choose theme</string>
<string name="choose_contrast">Contrast level</string>
<string name="contrast_standard">Standard</string>
<string name="contrast_medium">Medium</string>
<string name="contrast_high">High</string>
<string name="provide_location_to_mesh">Provide phone location to mesh</string>
<string name="use_homoglyph_characters_encoding">Compact encoding for Cyrillic</string>
<plurals name="delete_messages">

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 }

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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

View File

@@ -118,6 +118,7 @@ class UIViewModel(
}
val theme: StateFlow<Int> = uiPrefs.theme
val contrastLevel: StateFlow<Int> = uiPrefs.contrastLevel
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }

View File

@@ -169,7 +169,8 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
val uiPrefs = koinInject<UiPrefs>()
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)
}
}
}

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

View File

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

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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()
}
}
}
},
)
}

View File

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

View File

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