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 de3b5fd956
commit c668b209b2
11 files changed
+650 -30

No files matched your search

@@ -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
@@ -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"
}
}