refactor: extract repeated theme/color resolution patterns

Three duplicated patterns surfaced by the audit, each consolidated into
a single named helper.

1. ThemeOverrides.toColors and toAppColors had the same five-source
   priority chain (per-chat → per-user → own → preset → base) inlined
   for nine non-bubble fields. Move it into resolveColor so each call
   site states the data, not the resolution rule. (Bubble fields keep
   their wallpaper-aware logic — different rule, separate concern.)

2. The chat's effective theme — chatInfo.uiThemes.preferredMode resolved
   through ThemeManager.currentColors — was computed inline in ChatView
   (for SimpleXThemeOverride) and again in App.ActiveChatThemeProvider
   (for the chatlist column / Android modal stack). Pull both into a
   shared rememberActiveChatTheme. Both call sites also adopt the wider
   remember key (whole theme, not just base) that the toolbar fix
   already needed; behavior is unchanged when the chat has no override.

3. The toolbar code-output copy in Appearance reimplemented oklch
   conversion inline (Color → Oklab → atan2 → degrees) instead of
   calling the existing Color.toOklch() extension. Replace with the
   extension, which also adds a small near-zero-chroma guard the inline
   version was missing.

Pure refactor — no user-visible behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
another-simple-pixel
2026-05-13 13:45:42 -07:00
co-authored by Claude Opus 4.6
parent 19318576f3
commit d947050f31
4 changed files with 49 additions and 25 deletions
@@ -433,15 +433,7 @@ 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
}
val effectiveTheme = rememberActiveChatTheme(activeChat?.chatInfo, theme)
CompositionLocalProvider(LocalActiveTheme provides effectiveTheme, content = content)
}
@@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex
import chat.simplex.common.ui.theme.ThemeManager.toReadableHex
@@ -345,6 +346,22 @@ data class ThemesFile(
val themes: List<ThemeOverrides> = emptyList()
)
// Theme color priority: per-chat → per-user → this override's own colors →
// preset-wallpaper-derived theme → base palette. Used by toColors / toAppColors
// for every non-bubble field; bubble colors have wallpaper-aware logic that
// can't share this chain.
private fun resolveColor(
perChatColor: String?,
perUserColor: String?,
ownColor: String?,
presetColor: Color?,
baseColor: Color,
): Color = perChatColor?.colorFromReadableHex()
?: perUserColor?.colorFromReadableHex()
?: ownColor?.colorFromReadableHex()
?: presetColor
?: baseColor
// Spec: spec/services/theme.md#ThemeOverrides
@Serializable
data class ThemeOverrides (
@@ -395,12 +412,12 @@ data class ThemeOverrides (
DefaultTheme.BLACK -> BlackColorPalette
}
return baseColors.copy(
primary = perChatTheme?.primary?.colorFromReadableHex() ?: perUserTheme?.primary?.colorFromReadableHex() ?: colors.primary?.colorFromReadableHex() ?: presetWallpaperTheme?.primary ?: baseColors.primary,
primaryVariant = perChatTheme?.primaryVariant?.colorFromReadableHex() ?: perUserTheme?.primaryVariant?.colorFromReadableHex() ?: colors.primaryVariant?.colorFromReadableHex() ?: presetWallpaperTheme?.primaryVariant ?: baseColors.primaryVariant,
secondary = perChatTheme?.secondary?.colorFromReadableHex() ?: perUserTheme?.secondary?.colorFromReadableHex() ?: colors.secondary?.colorFromReadableHex() ?: presetWallpaperTheme?.secondary ?: baseColors.secondary,
secondaryVariant = perChatTheme?.secondaryVariant?.colorFromReadableHex() ?: perUserTheme?.secondaryVariant?.colorFromReadableHex() ?: colors.secondaryVariant?.colorFromReadableHex() ?: presetWallpaperTheme?.secondaryVariant ?: baseColors.secondaryVariant,
background = perChatTheme?.background?.colorFromReadableHex() ?: perUserTheme?.background?.colorFromReadableHex() ?: colors.background?.colorFromReadableHex() ?: presetWallpaperTheme?.background ?: baseColors.background,
surface = perChatTheme?.surface?.colorFromReadableHex() ?: perUserTheme?.surface?.colorFromReadableHex() ?: colors.surface?.colorFromReadableHex() ?: presetWallpaperTheme?.surface ?: baseColors.surface,
primary = resolveColor(perChatTheme?.primary, perUserTheme?.primary, colors.primary, presetWallpaperTheme?.primary, baseColors.primary),
primaryVariant = resolveColor(perChatTheme?.primaryVariant, perUserTheme?.primaryVariant, colors.primaryVariant, presetWallpaperTheme?.primaryVariant, baseColors.primaryVariant),
secondary = resolveColor(perChatTheme?.secondary, perUserTheme?.secondary, colors.secondary, presetWallpaperTheme?.secondary, baseColors.secondary),
secondaryVariant = resolveColor(perChatTheme?.secondaryVariant, perUserTheme?.secondaryVariant, colors.secondaryVariant, presetWallpaperTheme?.secondaryVariant, baseColors.secondaryVariant),
background = resolveColor(perChatTheme?.background, perUserTheme?.background, colors.background, presetWallpaperTheme?.background, baseColors.background),
surface = resolveColor(perChatTheme?.surface, perUserTheme?.surface, colors.surface, presetWallpaperTheme?.surface, baseColors.surface),
)
}
@@ -417,9 +434,9 @@ data class ThemeOverrides (
val receivedMessageFallback = colors.receivedMessage?.colorFromReadableHex() ?: presetWallpaperTheme?.receivedMessage ?: baseColors.receivedMessage
val receivedQuoteFallback = colors.receivedQuote?.colorFromReadableHex() ?: presetWallpaperTheme?.receivedQuote ?: baseColors.receivedQuote
return baseColors.copy(
title = perChatTheme?.title?.colorFromReadableHex() ?: perUserTheme?.title?.colorFromReadableHex() ?: colors.title?.colorFromReadableHex() ?: presetWallpaperTheme?.title ?: baseColors.title,
primaryVariant2 = perChatTheme?.primaryVariant2?.colorFromReadableHex() ?: perUserTheme?.primaryVariant2?.colorFromReadableHex() ?: colors.primaryVariant2?.colorFromReadableHex() ?: presetWallpaperTheme?.primaryVariant2 ?: baseColors.primaryVariant2,
toolbar = perChatTheme?.toolbar?.colorFromReadableHex() ?: perUserTheme?.toolbar?.colorFromReadableHex() ?: colors.toolbar?.colorFromReadableHex() ?: presetWallpaperTheme?.toolbar ?: baseColors.toolbar,
title = resolveColor(perChatTheme?.title, perUserTheme?.title, colors.title, presetWallpaperTheme?.title, baseColors.title),
primaryVariant2 = resolveColor(perChatTheme?.primaryVariant2, perUserTheme?.primaryVariant2, colors.primaryVariant2, presetWallpaperTheme?.primaryVariant2, baseColors.primaryVariant2),
toolbar = resolveColor(perChatTheme?.toolbar, perUserTheme?.toolbar, colors.toolbar, presetWallpaperTheme?.toolbar, baseColors.toolbar),
sentMessage = if (perChatTheme?.sentMessage != null) perChatTheme.sentMessage.colorFromReadableHex()
else if (perUserTheme != null && (perChatWallpaperType == null || perUserWallpaperType == null || perChatWallpaperType.sameType(perUserWallpaperType))) perUserTheme.sentMessage?.colorFromReadableHex() ?: sentMessageFallback
else sentMessageFallback,
@@ -882,6 +899,24 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
)
}
/** Resolve the active theme to apply to a UI surface that is "owned" by a
* specific chat. Returns the chat's theme override (resolved through the
* per-user / app-settings chain) when one exists; otherwise returns the
* passed [theme] unchanged. Same logic used at chat scope (SimpleXThemeOverride
* caller in ChatView) and at scopes that follow the active chat from the
* outside (ActiveChatThemeProvider in App). */
@Composable
fun rememberActiveChatTheme(chatInfo: ChatInfo?, theme: ThemeManager.ActiveTheme): ThemeManager.ActiveTheme =
remember(chatInfo, theme) {
val perChatTheme = when (chatInfo) {
is ChatInfo.Direct -> chatInfo.contact.uiThemes?.preferredMode(!theme.colors.isLight)
is ChatInfo.Group -> chatInfo.groupInfo.uiThemes?.preferredMode(!theme.colors.isLight)
else -> null
}
if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
else theme
}
@Composable
fun SimpleXThemeOverride(theme: ThemeManager.ActiveTheme, content: @Composable () -> Unit) {
MaterialTheme(
@@ -252,11 +252,11 @@ fun ChatView(
when (chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
var groupMembersJob: Job = remember { Job() }
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
val theme by CurrentColors.collectAsState()
val effectiveTheme = rememberActiveChatTheme(chatInfo, theme)
val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) }
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
SimpleXThemeOverride(effectiveTheme) {
val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value ->
val sameText = searchText.value == value
// showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it
@@ -1332,10 +1332,7 @@ object AppearanceScope {
if (name == ThemeColor.TOOLBAR && appPrefs.developerTools.get()) {
SectionSpacer()
val toolbarCode = if (currentColor.alpha >= 0.01f) {
val oklab = currentColor.convert(androidx.compose.ui.graphics.colorspace.ColorSpaces.Oklab)
val L = oklab.component1(); val a = oklab.component2(); val b = oklab.component3()
val C = kotlin.math.sqrt(a * a + b * b)
val H = Math.toDegrees(kotlin.math.atan2(b.toDouble(), a.toDouble())).toFloat().let { if (it < 0) it + 360f else it }
val (L, C, H) = currentColor.toOklch()
"toolbar = oklch(${L}f, ${C}f, ${H}f, ${currentColor.alpha}f),"
} else "// toolbar: no tint (transparent)"
val preset = (wallpaperType as? WallpaperType.Preset)?.let { PresetWallpaper.from(it.filename) }