User theme: customizable bubble colors + background (color/image) in Settings → Appearance
- UserTheme model (ARGB ints) + LocalUserTheme, persisted via SecureStore - HSV color picker dialog (Apply keeps open, OK applies+closes, Cancel) - Background: solid color or image (SAF on Android, JFileChooser on desktop) - Reasoning block: 0.6 black overlay on agent bubble color (adapts automatically) - Contrast-aware text on bubbles + pinned contentColor on root Surface
This commit is contained in:
1 parent
86a4c8ee70
commit
0e8f69a43c
11 files changed
+650
-30
No files matched your search
@@ -1,14 +1,18 @@
|
||||
package iris
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
@@ -17,6 +21,8 @@ import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.rememberBackgroundImage
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
@@ -47,24 +53,50 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
val theme by controller.theme.collectAsState()
|
||||
|
||||
IrisTheme {
|
||||
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||
val s = state
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// StatusChip + connection banner show the link state without
|
||||
// blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
CompositionLocalProvider(LocalUserTheme provides theme) {
|
||||
// The color goes through Surface's `color` parameter: a
|
||||
// Modifier.background on the Surface would be painted *under* the
|
||||
// Surface's own colorScheme.surface fill and never show through.
|
||||
// contentColor is pinned to the app's light text color — Surface
|
||||
// would otherwise derive it from the user's background (black
|
||||
// text on a light background), which would also darken text that
|
||||
// sits on the app's own dark panels.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = theme.background,
|
||||
contentColor = IrisColors.onBackground,
|
||||
) {
|
||||
val bgImage = rememberBackgroundImage(theme)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val bmp = bgImage
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
val s = state
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// StatusChip + connection banner show the link state without
|
||||
// blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ interface SecureStore {
|
||||
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
||||
var reasoningAutoCollapse: Boolean
|
||||
|
||||
/** UI setting: user bubble color (ARGB int). */
|
||||
var userBubbleColor: Int
|
||||
|
||||
/** UI setting: agent bubble color (ARGB int). */
|
||||
var agentBubbleColor: Int
|
||||
|
||||
/** UI setting: background mode — "color" or "image". */
|
||||
var backgroundMode: String
|
||||
|
||||
/** UI setting: background color (ARGB int). */
|
||||
var backgroundColor: Int
|
||||
|
||||
/** UI setting: background image path (empty when unused). */
|
||||
var backgroundImagePath: String
|
||||
|
||||
fun savePairing(url: String, token: String)
|
||||
fun clear()
|
||||
}
|
||||
@@ -20,12 +20,23 @@ data class PickedFile(
|
||||
@Composable
|
||||
expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit)
|
||||
|
||||
/**
|
||||
* Image-only file picker (SAF on Android, FileChooser on desktop). The picked
|
||||
* image is copied into the app's persistent storage; [onPicked] receives the
|
||||
* staged file, or null when the user cancels.
|
||||
*/
|
||||
@Composable
|
||||
expect fun ImageFilePicker(onPicked: (PickedFile?) -> Unit)
|
||||
|
||||
/** Base directory for the media cache (app-specific cache dir). */
|
||||
expect fun mediaCacheBaseDir(): String
|
||||
|
||||
/** Load an image from a local path for display. Null on failure. */
|
||||
expect suspend fun loadMediaImage(path: String): ImageBitmap?
|
||||
|
||||
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
|
||||
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
|
||||
|
||||
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
|
||||
@Composable
|
||||
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
|
||||
|
||||
@@ -62,6 +62,8 @@ import iris.protocol.historyFrame
|
||||
import iris.protocol.messageDeleteFrame
|
||||
import iris.protocol.searchFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -158,6 +160,48 @@ class IrisController(
|
||||
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
|
||||
}
|
||||
|
||||
// ── Appearance (Settings → Appearance) ────────────────────────────────
|
||||
// User-customizable bubble colors + background (color or image).
|
||||
// Persisted as ARGB ints + an image path (see UserTheme).
|
||||
private val _theme = MutableStateFlow(
|
||||
UserTheme(
|
||||
userBubbleColor = store.userBubbleColor,
|
||||
agentBubbleColor = store.agentBubbleColor,
|
||||
backgroundMode = BackgroundMode.entries.firstOrNull {
|
||||
it.name.equals(store.backgroundMode, ignoreCase = true)
|
||||
} ?: BackgroundMode.Color,
|
||||
backgroundColor = store.backgroundColor,
|
||||
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
|
||||
)
|
||||
)
|
||||
val theme: StateFlow<UserTheme> = _theme.asStateFlow()
|
||||
|
||||
fun setUserBubbleColor(argb: Int) = updateTheme { it.copy(userBubbleColor = argb) }
|
||||
|
||||
fun setAgentBubbleColor(argb: Int) = updateTheme { it.copy(agentBubbleColor = argb) }
|
||||
|
||||
fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) }
|
||||
|
||||
fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) }
|
||||
|
||||
fun setBackgroundImagePath(path: String?) = updateTheme { it.copy(backgroundImagePath = path) }
|
||||
|
||||
/** Clears the background image and falls back to the solid color. */
|
||||
fun removeBackgroundImage() =
|
||||
updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
|
||||
|
||||
fun resetTheme() = updateTheme { UserTheme() }
|
||||
|
||||
private fun updateTheme(transform: (UserTheme) -> UserTheme) {
|
||||
val t = transform(_theme.value)
|
||||
_theme.value = t
|
||||
store.userBubbleColor = t.userBubbleColor
|
||||
store.agentBubbleColor = t.agentBubbleColor
|
||||
store.backgroundMode = t.backgroundMode.name.lowercase()
|
||||
store.backgroundColor = t.backgroundColor
|
||||
store.backgroundImagePath = t.backgroundImagePath.orEmpty()
|
||||
}
|
||||
|
||||
// ── M3: search state ──────────────────────────────────────────────────
|
||||
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
|
||||
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
|
||||
|
||||
@@ -107,7 +107,9 @@ import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.rightClick
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.avatarColor
|
||||
import iris.ui.theme.contrastText
|
||||
import iris.util.formatDayLabel
|
||||
import iris.util.formatTime
|
||||
import iris.util.localDayKey
|
||||
@@ -1530,15 +1532,16 @@ private fun MessageBubble(
|
||||
) {
|
||||
val isUser = msg.role == ROLE_USER
|
||||
val isCommentary = msg.isCommentary
|
||||
val userTheme = LocalUserTheme.current
|
||||
val bubbleColor = when {
|
||||
isUser -> IrisColors.bubbleUser
|
||||
isUser -> userTheme.userBubble
|
||||
isCommentary -> IrisColors.bubbleCommentary
|
||||
else -> IrisColors.bubbleAssistant
|
||||
else -> userTheme.agentBubble
|
||||
}
|
||||
val textColor = when {
|
||||
isUser -> Color.White
|
||||
isUser -> userTheme.userBubble.contrastText()
|
||||
isCommentary -> IrisColors.onSurface.copy(alpha = 0.55f)
|
||||
else -> IrisColors.onSurface
|
||||
else -> userTheme.agentBubble.contrastText()
|
||||
}
|
||||
val time = formatTime(msg.ts)
|
||||
Row(
|
||||
@@ -1575,7 +1578,7 @@ private fun MessageBubble(
|
||||
) {
|
||||
// Reasoning block above the answer (assistant, non-commentary).
|
||||
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
|
||||
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse)
|
||||
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
if (isUser) {
|
||||
@@ -1852,17 +1855,27 @@ private fun AttachmentChip(att: IrisController.PendingAttachment, onRemove: () -
|
||||
* expanded. Tap to toggle.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
|
||||
private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor: Color) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var expanded by remember(autoCollapse) {
|
||||
mutableStateOf(!autoCollapse || reasoning.length <= 240)
|
||||
}
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
// A semi-transparent black overlay darkens whatever the user's agent bubble
|
||||
// color is, so the reasoning block always matches the bubble while staying
|
||||
// visually distinct from the message content. Text is derived from the
|
||||
// effective (darkened) background so it stays readable on any bubble color.
|
||||
val effective = Color(
|
||||
red = bubbleColor.red * 0.4f,
|
||||
green = bubbleColor.green * 0.4f,
|
||||
blue = bubbleColor.blue * 0.4f,
|
||||
)
|
||||
val textColor = effective.contrastText()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.reasoningPanel)
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -1870,7 +1883,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
|
||||
"💭 Reasoning",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = IrisColors.textSecondary,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { expanded = !expanded },
|
||||
@@ -1884,7 +1897,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
|
||||
Text(
|
||||
if (expanded) "▾" else "▸",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textSecondary,
|
||||
color = textColor,
|
||||
modifier = Modifier.clickable { expanded = !expanded },
|
||||
)
|
||||
}
|
||||
@@ -1893,7 +1906,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
|
||||
reasoning,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textMuted,
|
||||
color = textColor.copy(alpha = 0.75f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -16,36 +19,56 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import iris.platform.ImageFilePicker
|
||||
import iris.platform.loadScaledImage
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Settings screen (post-M7): app-level preferences, opened from the channel
|
||||
* drawer / rail. More settings land here over time.
|
||||
*/
|
||||
/** Which color the picker dialog is editing. */
|
||||
private enum class PickerTarget { UserBubble, AgentBubble, Background }
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val theme = LocalUserTheme.current
|
||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(IrisColors.background)) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -126,6 +149,117 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Appearance",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("💬 Your bubble", fontSize = 14.sp)
|
||||
Text(
|
||||
"Color of your message bubbles",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ColorSwatch(theme.userBubble) { pickerTarget = PickerTarget.UserBubble }
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("🤖 Agent bubble", fontSize = 14.sp)
|
||||
Text(
|
||||
"Color of Hermes' message bubbles",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ColorSwatch(theme.agentBubble) { pickerTarget = PickerTarget.AgentBubble }
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Text("🖼 Background", fontSize = 14.sp)
|
||||
Text(
|
||||
"Solid color or image behind the chat",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TopicChip(
|
||||
label = "Color",
|
||||
selected = theme.backgroundMode == BackgroundMode.Color,
|
||||
onClick = { controller.setBackgroundMode(BackgroundMode.Color) },
|
||||
)
|
||||
TopicChip(
|
||||
label = "Image",
|
||||
selected = theme.backgroundMode == BackgroundMode.Image,
|
||||
onClick = { controller.setBackgroundMode(BackgroundMode.Image) },
|
||||
)
|
||||
}
|
||||
if (theme.backgroundMode == BackgroundMode.Color) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Background color", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
}
|
||||
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BackgroundPreview(theme.backgroundImagePath)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
|
||||
else fileNameOf(theme.backgroundImagePath!!),
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ImageFilePicker { picked ->
|
||||
picked?.let { controller.setBackgroundImagePath(it.path) }
|
||||
}
|
||||
}
|
||||
if (!theme.backgroundImagePath.isNullOrBlank()) {
|
||||
TextButton(onClick = { controller.removeBackgroundImage() }) {
|
||||
Text("Remove image", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Reset appearance", fontSize = 14.sp)
|
||||
Text(
|
||||
"Restore the default colors and background",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { controller.resetTheme() }) {
|
||||
Text("Reset", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Tools",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
@@ -150,6 +284,40 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (pickerTarget) {
|
||||
PickerTarget.UserBubble -> ColorPickerDialog(
|
||||
title = "Your bubble",
|
||||
initialArgb = theme.userBubbleColor,
|
||||
onApply = { controller.setUserBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setUserBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
PickerTarget.AgentBubble -> ColorPickerDialog(
|
||||
title = "Agent bubble",
|
||||
initialArgb = theme.agentBubbleColor,
|
||||
onApply = { controller.setAgentBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setAgentBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
PickerTarget.Background -> ColorPickerDialog(
|
||||
title = "Background",
|
||||
initialArgb = theme.backgroundColor,
|
||||
onApply = { controller.setBackgroundColor(it) },
|
||||
onConfirm = {
|
||||
controller.setBackgroundColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,4 +354,127 @@ private fun SettingsCard(content: @Composable () -> Unit) {
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clickable rounded chip showing a color; opens the picker on tap. */
|
||||
@Composable
|
||||
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
/** Small thumbnail of the current background image (empty panel when none). */
|
||||
@Composable
|
||||
private fun BackgroundPreview(path: String?) {
|
||||
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(path) {
|
||||
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HSV color picker dialog (hue / saturation / brightness sliders).
|
||||
* [onApply] applies the color and keeps the dialog open for further tweaks;
|
||||
* [onConfirm] applies and closes; [onDismiss] closes without applying.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColorPickerDialog(
|
||||
title: String,
|
||||
initialArgb: Int,
|
||||
onApply: (Int) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val initial = remember { argbToHsv(initialArgb) }
|
||||
var hue by remember { mutableFloatStateOf(initial[0]) }
|
||||
var sat by remember { mutableFloatStateOf(initial[1]) }
|
||||
var bri by remember { mutableFloatStateOf(initial[2]) }
|
||||
val color = Color.hsv(hue, sat, bri)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
|
||||
Slider(value = sat, onValueChange = { sat = it }, valueRange = 0f..1f)
|
||||
Slider(value = bri, onValueChange = { bri = it }, valueRange = 0f..1f)
|
||||
Text(
|
||||
"#%06X".format(color.toArgbInt() and 0xFFFFFF),
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onApply(color.toArgbInt()) }) { Text("Apply") }
|
||||
TextButton(onClick = { onConfirm(color.toArgbInt()) }) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Compose Color → ARGB int (no Color.toArgb in common code). */
|
||||
private fun Color.toArgbInt(): Int {
|
||||
fun c(v: Float) = (v * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)
|
||||
}
|
||||
|
||||
/** ARGB int → [hue (deg), saturation, brightness] (alpha ignored). */
|
||||
private fun argbToHsv(argb: Int): FloatArray {
|
||||
val r = ((argb shr 16) and 0xFF) / 255f
|
||||
val g = ((argb shr 8) and 0xFF) / 255f
|
||||
val b = (argb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
var hue = 0f
|
||||
if (delta != 0f) {
|
||||
hue = when (max) {
|
||||
r -> ((g - b) / delta) % 6f
|
||||
g -> (b - r) / delta + 2f
|
||||
else -> (r - g) / delta + 4f
|
||||
}
|
||||
hue *= 60f
|
||||
if (hue < 0f) hue += 360f
|
||||
}
|
||||
val sat = if (max == 0f) 0f else delta / max
|
||||
return floatArrayOf(hue, sat, max)
|
||||
}
|
||||
|
||||
/** File name from a path (no java.io.File in common code). */
|
||||
private fun fileNameOf(path: String): String =
|
||||
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
|
||||
@@ -3,7 +3,15 @@ package iris.ui.theme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import iris.platform.loadScaledImage
|
||||
|
||||
/**
|
||||
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||
@@ -34,7 +42,6 @@ object IrisColors {
|
||||
val chip = Color(0xFF2A2E3B)
|
||||
val chipSelected = Color(0xFF2A3550)
|
||||
val rowSelected = Color(0xFF232838)
|
||||
val reasoningPanel = Color(0xFF1E212B)
|
||||
val divider = Color(0xFF2A2E3B)
|
||||
|
||||
// Status
|
||||
@@ -69,4 +76,54 @@ fun IrisTheme(content: @Composable () -> Unit) {
|
||||
fun avatarColor(name: String): Color {
|
||||
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
|
||||
return Color.hsv(hue, 0.5f, 0.75f)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-customizable appearance (Settings → Appearance). Colors are stored as
|
||||
* ARGB ints so they can be persisted without Compose types.
|
||||
*/
|
||||
data class UserTheme(
|
||||
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
|
||||
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
|
||||
val backgroundMode: BackgroundMode = BackgroundMode.Color,
|
||||
val backgroundColor: Int = DEFAULT_BACKGROUND,
|
||||
val backgroundImagePath: String? = null,
|
||||
) {
|
||||
val userBubble: Color get() = Color(userBubbleColor)
|
||||
val agentBubble: Color get() = Color(agentBubbleColor)
|
||||
val background: Color get() = Color(backgroundColor)
|
||||
|
||||
companion object {
|
||||
val DEFAULT_USER_BUBBLE: Int = 0xFF4F7CFF.toInt()
|
||||
val DEFAULT_AGENT_BUBBLE: Int = 0xFF2A2E3B.toInt()
|
||||
val DEFAULT_BACKGROUND: Int = 0xFF1B1E28.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Background style: solid color or image. */
|
||||
enum class BackgroundMode { Color, Image }
|
||||
|
||||
/** Current user theme, provided at the app root (IrisApp). */
|
||||
val LocalUserTheme = staticCompositionLocalOf { UserTheme() }
|
||||
|
||||
/** Black or white, whichever reads better on [this]. */
|
||||
fun Color.contrastText(): Color {
|
||||
val luminance = 0.299f * red + 0.587f * green + 0.114f * blue
|
||||
return if (luminance > 0.6f) Color(0xFF1B1E28) else Color.White
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the theme's background image (image mode only), scaled to at most
|
||||
* 2048 px on the longest edge. Null in color mode or on decode failure — the
|
||||
* caller then falls back to the solid background color.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
|
||||
val path = theme.backgroundImagePath
|
||||
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
|
||||
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(wantImage, path) {
|
||||
image = if (wantImage) loadScaledImage(path!!, 2048) else null
|
||||
}
|
||||
return image
|
||||
}
|
||||
Reference in new issue
Block a user