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:
ARIA committed 2026-08-20 19:14:35 +02:00
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)
}
}
}
}
}