diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt index 5402cd3..81c3695 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt @@ -82,6 +82,31 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { } } +@Composable +actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + onPicked(null) + return@rememberLauncherForActivityResult + } + try { + val mime = context.contentResolver.getType(uri) ?: "image/jpeg" + val name = queryDisplayName(context, uri) ?: "background" + // Persistent (filesDir), unlike the upload staging dir in cacheDir. + val dir = File(context.filesDir, "theme").apply { mkdirs() } + val target = File(dir, "background${extForMime(mime)}") + context.contentResolver.openInputStream(uri)!!.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + onPicked(PickedFile(target.absolutePath, name, mime, target.length())) + } catch (e: Exception) { + onPicked(null) + } + } + Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") } +} + private fun queryDisplayName(context: Context, uri: Uri): String? { context.contentResolver.query(uri, null, null, null, null)?.use { c -> val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) @@ -102,6 +127,22 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp } } +actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) { + try { + // inJustDecodeBounds fills [bounds] and returns null by design — only + // the outWidth/outHeight are meaningful here. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + BitmapFactory.decodeFile(path, opts)?.asImageBitmap() + } catch (e: Exception) { + null + } +} + @Composable actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { val path = media.localPath diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 14027de..b3164e3 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -6,6 +6,7 @@ import android.os.Build import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import iris.data.SecureStore +import iris.ui.theme.UserTheme import java.util.UUID /** @@ -113,6 +114,26 @@ class AndroidSecureStore(context: Context) : SecureStore { get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply() + override var userBubbleColor: Int + get() = prefs.getInt(KEY_USER_BUBBLE_COLOR, UserTheme.DEFAULT_USER_BUBBLE) + set(value) = prefs.edit().putInt(KEY_USER_BUBBLE_COLOR, value).apply() + + override var agentBubbleColor: Int + get() = prefs.getInt(KEY_AGENT_BUBBLE_COLOR, UserTheme.DEFAULT_AGENT_BUBBLE) + set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply() + + override var backgroundMode: String + get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" } + set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply() + + override var backgroundColor: Int + get() = prefs.getInt(KEY_BACKGROUND_COLOR, UserTheme.DEFAULT_BACKGROUND) + set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply() + + override var backgroundImagePath: String + get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty() + set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply() + override fun savePairing(url: String, token: String) { serverUrl = url this.token = token @@ -136,5 +157,10 @@ class AndroidSecureStore(context: Context) : SecureStore { const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_STREAMING_ENABLED = "streaming_enabled" const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse" + const val KEY_USER_BUBBLE_COLOR = "user_bubble_color" + const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color" + const val KEY_BACKGROUND_MODE = "background_mode" + const val KEY_BACKGROUND_COLOR = "background_color" + const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path" } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt index df4f5b9..5f77bbc 100644 --- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt +++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt @@ -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) + } + } } } } diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index 9ced608..14fb78d 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -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() } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt index dc34964..5abfec4 100644 --- a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index cfdd0ac..0ba3605 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -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 = _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>(emptyList()) val searchResults: StateFlow> = _searchResults.asStateFlow() diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 5de6cde..f0908a8 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -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), ) } } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt index a8ba875..02477d5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt @@ -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(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() } -} \ No newline at end of file +} + +/** 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(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('\\') } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt b/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt index 744ff70..9e2c096 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt @@ -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(null) } + LaunchedEffect(wantImage, path) { + image = if (wantImage) loadScaledImage(path!!, 2048) else null + } + return image } \ No newline at end of file diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt index 69021ca..c4ed3eb 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import iris.data.MediaItem +import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import java.io.File import java.net.StandardProtocolFamily @@ -40,6 +41,7 @@ import java.nio.channels.SocketChannel import java.util.concurrent.TimeUnit import javax.imageio.ImageIO import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -93,6 +95,33 @@ private fun pickFile(): PickedFile? { ) } +@Composable +actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { + Button(onClick = { + val chooser = JFileChooser().apply { + fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif") + } + if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) { + onPicked(null) + return@Button + } + val f = chooser.selectedFile ?: return@Button + try { + // Copy into the persistent ~/.iris dir so the theme survives + // deletion of the original file. + val dir = File(File(System.getProperty("user.home"), ".iris"), "theme").apply { mkdirs() } + val ext = f.name.substringAfterLast('.', "img").lowercase() + val target = File(dir, "background.$ext") + f.copyTo(target, overwrite = true) + onPicked(PickedFile(target.absolutePath, f.name, guessMime(f.name), target.length())) + } catch (e: Exception) { + onPicked(null) + } + }) { + Text("Choose image…") + } +} + private fun guessMime(name: String): String { val ext = name.substringAfterLast('.', "").lowercase() return when (ext) { @@ -131,6 +160,26 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp } } +actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) { + try { + val img = ImageIO.read(File(path)) ?: return@withContext null + val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f) + if (scale >= 1f) { + img.toComposeImageBitmap() + } else { + val sw = (img.width * scale).toInt().coerceAtLeast(1) + val sh = (img.height * scale).toInt().coerceAtLeast(1) + val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB) + val g = scaled.createGraphics() + g.drawImage(img, 0, 0, sw, sh, null) + g.dispose() + scaled.toComposeImageBitmap() + } + } catch (_: Exception) { + null + } +} + /** * M6: inline media playback backed by mpv (docs/11 §11.2). * diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index 09b34cf..6f82a29 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -2,6 +2,7 @@ package iris.platform import iris.data.SecureStore import iris.protocol.IrisJson +import iris.ui.theme.UserTheme import java.io.File import java.security.SecureRandom import java.util.UUID @@ -38,6 +39,11 @@ class DesktopSecureStore : SecureStore { val toolDetail: String = "truncated", val streamingEnabled: Boolean = true, val reasoningAutoCollapse: Boolean = true, + val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, + val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, + val backgroundMode: String = "color", + val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND, + val backgroundImagePath: String = "", ) init { @@ -165,6 +171,41 @@ class DesktopSecureStore : SecureStore { save(d.copy(reasoningAutoCollapse = value)) } + override var userBubbleColor: Int + get() = load().userBubbleColor + set(value) { + val d = load() + save(d.copy(userBubbleColor = value)) + } + + override var agentBubbleColor: Int + get() = load().agentBubbleColor + set(value) { + val d = load() + save(d.copy(agentBubbleColor = value)) + } + + override var backgroundMode: String + get() = load().backgroundMode.ifBlank { "color" } + set(value) { + val d = load() + save(d.copy(backgroundMode = value)) + } + + override var backgroundColor: Int + get() = load().backgroundColor + set(value) { + val d = load() + save(d.copy(backgroundColor = value)) + } + + override var backgroundImagePath: String + get() = load().backgroundImagePath + set(value) { + val d = load() + save(d.copy(backgroundImagePath = value)) + } + override fun savePairing(url: String, token: String) { val d = load() save(d.copy(serverUrl = url.trim()))