diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 4c8bfbc3ca..a4b1badda7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 2d4dfd53d7..5d4cf64708 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -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 = 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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index f42969a73f..b73401c8eb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 22f0bf45ed..66121e5a29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -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) }