toolbar: respect per-chat theme override

The toolbar background was always computed from the global CurrentColors
state flow, so a chat with a custom theme override (its own wallpaper or
color scheme) showed a toolbar tinted to the global Appearance scheme
rather than the chat's. On desktop two-pane the chatlist toolbar sat
next to the chat toolbar in different colors; on Android the chat info
and chat-customize modals kept the global tint above and below the
chat-themed content.

Expose the current theme as a new LocalActiveTheme composition local,
provided by SimpleXTheme (global scope) and SimpleXThemeOverride (chat
scope). panelBackgroundColor reads everything — both the wallpaper-hue
tint and the elevation fallback — from this local, so it tints to
whatever scope it renders in. A small ActiveChatThemeProvider helper
propagates the active chat's effective theme to UI surfaces that sit
outside the chat's SimpleXThemeOverride: the chatlist column on desktop,
and the fullscreen modal stack on Android.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
another-simple-pixel
2026-05-13 13:25:21 -07:00
parent a920e50c28
commit bd9d31c5f4
3 changed files with 64 additions and 38 deletions
@@ -203,7 +203,9 @@ fun MainScreen() {
}
if (appPlatform.isAndroid) {
AndroidWrapInCallLayout {
ModalManager.fullscreen.showInView()
ActiveChatThemeProvider {
ModalManager.fullscreen.showInView()
}
}
SwitchingUsersView()
}
@@ -422,45 +424,68 @@ fun EndPartOfScreen() {
ModalManager.end.showInView()
}
// Toolbars rendered alongside or on behalf of the active chat (the chatlist column
// in desktop two-pane, modals opened from a chat on Android) need to share that
// chat's theme. Wrap such UI surfaces with this so panelBackgroundColor() reads
// the right tint via LocalActiveTheme.
@Composable
private fun ActiveChatThemeProvider(content: @Composable () -> Unit) {
val theme by CurrentColors.collectAsState()
val chatId by chatModel.chatId
val activeChat = chatId?.let { id -> chatModel.chats.value.firstOrNull { it.chatInfo.id == id } }
val effectiveTheme = remember(activeChat?.chatInfo, theme) {
val perChatTheme = when (val ci = activeChat?.chatInfo) {
is ChatInfo.Direct -> ci.contact.uiThemes?.preferredMode(!theme.colors.isLight)
is ChatInfo.Group -> ci.groupInfo.uiThemes?.preferredMode(!theme.colors.isLight)
else -> null
}
if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
else theme
}
CompositionLocalProvider(LocalActiveTheme provides effectiveTheme, content = content)
}
// Spec: spec/client/navigation.md#DesktopScreen
@Composable
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
ActiveChatThemeProvider {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
}
}
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
if (chatModel.centerPanelBackgroundClickHandler == null || chatModel.centerPanelBackgroundClickHandler?.invoke() == false) {
ModalManager.start.closeModals()
userPickerState.value = AnimatedViewState.HIDING
}
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
ModalManager.fullscreen.showInView()
}
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
if (chatModel.centerPanelBackgroundClickHandler == null || chatModel.centerPanelBackgroundClickHandler?.invoke() == false) {
ModalManager.start.closeModals()
userPickerState.value = AnimatedViewState.HIDING
}
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
ModalManager.fullscreen.showInView()
}
@Composable
@@ -3,7 +3,6 @@ package chat.simplex.common.ui.theme
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import chat.simplex.common.platform.appPlatform
@@ -57,13 +56,12 @@ val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.prima
* BLACK and SIMPLEX themes are not tinted (BLACK keeps pure dark, SIMPLEX has its own custom panel). */
@Composable
fun panelBackgroundColor(): Color {
return currentWallpaperPanelTint()
?: MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
val state = LocalActiveTheme.current
return currentWallpaperPanelTint(state)
?: state.colors.background.mixWith(state.colors.onBackground, 0.97f)
}
@Composable
private fun currentWallpaperPanelTint(): Color? {
val state = CurrentColors.collectAsState().value
private fun currentWallpaperPanelTint(state: ThemeManager.ActiveTheme): Color? {
val type = state.wallpaper.type as? WallpaperType.Preset ?: return null
val preset = PresetWallpaper.from(type.filename) ?: return null
val hue = preset.hue(state.base)
@@ -792,6 +792,7 @@ expect fun isSystemInDarkTheme(): Boolean
internal val LocalAppColors = staticCompositionLocalOf { LightColorPaletteApp }
internal val LocalAppWallpaper = staticCompositionLocalOf { AppWallpaper() }
internal val LocalActiveTheme = staticCompositionLocalOf { CurrentColors.value }
val MaterialTheme.appColors: AppColors
@Composable
@@ -873,6 +874,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LocalContentColor provides MaterialTheme.colors.onBackground,
LocalAppColors provides rememberedAppColors,
LocalAppWallpaper provides rememberedWallpaper,
LocalActiveTheme provides theme,
LocalDensity provides density,
content = content
)
@@ -901,6 +903,7 @@ fun SimpleXThemeOverride(theme: ThemeManager.ActiveTheme, content: @Composable (
LocalContentColor provides MaterialTheme.colors.onBackground,
LocalAppColors provides rememberedAppColors,
LocalAppWallpaper provides rememberedWallpaper,
LocalActiveTheme provides theme,
content = content)
}
)