mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-15 19:38:15 -04:00
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 = {}) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user