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
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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user