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

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