diff --git a/app/androidApp/src/main/assets/backdrops/pexels-bogdankrupin-12049700.jpg b/app/androidApp/src/main/assets/backdrops/pexels-bogdankrupin-12049700.jpg new file mode 100644 index 0000000..dc460cb Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-bogdankrupin-12049700.jpg differ diff --git a/app/androidApp/src/main/assets/backdrops/pexels-bosichong-27940302.jpg b/app/androidApp/src/main/assets/backdrops/pexels-bosichong-27940302.jpg new file mode 100644 index 0000000..87930cc Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-bosichong-27940302.jpg differ diff --git a/app/androidApp/src/main/assets/backdrops/pexels-farhan-najeer-644774196-32490483.jpg b/app/androidApp/src/main/assets/backdrops/pexels-farhan-najeer-644774196-32490483.jpg new file mode 100644 index 0000000..9afb6f9 Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-farhan-najeer-644774196-32490483.jpg differ diff --git a/app/androidApp/src/main/assets/backdrops/pexels-soubhagya23-8060218.jpg b/app/androidApp/src/main/assets/backdrops/pexels-soubhagya23-8060218.jpg new file mode 100644 index 0000000..1651100 Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-soubhagya23-8060218.jpg differ diff --git a/app/androidApp/src/main/assets/backdrops/pexels-steve-29390703.jpg b/app/androidApp/src/main/assets/backdrops/pexels-steve-29390703.jpg new file mode 100644 index 0000000..99f1c88 Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-steve-29390703.jpg differ diff --git a/app/androidApp/src/main/assets/backdrops/pexels-yunszyveli-12368637.jpg b/app/androidApp/src/main/assets/backdrops/pexels-yunszyveli-12368637.jpg new file mode 100644 index 0000000..f8c3302 Binary files /dev/null and b/app/androidApp/src/main/assets/backdrops/pexels-yunszyveli-12368637.jpg differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f87a81d..89c69c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,4 +23,4 @@ plugins { tasks.register("clean") { delete(rootProject.layout.buildDirectory) -} \ No newline at end of file +} diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 12f42e8..045f69b 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -92,4 +92,4 @@ kotlin { implementation("com.google.firebase:firebase-messaging:25.1.2") } } -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt index 4471cfa..2a1a10c 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt @@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.FileProvider import androidx.core.net.toUri -import androidx.media3.common.MediaItem as M3MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import iris.data.MediaItem import iris.media.extForMime import iris.protocol.KIND_VIDEO -import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File +import androidx.media3.common.MediaItem as M3MediaItem /** App context holder (set in MainActivity.onCreate). */ object AndroidEnv { @@ -59,24 +59,25 @@ object AndroidEnv { @Composable actual fun MediaFilePicker(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) ?: "application/octet-stream" - val name = queryDisplayName(context, uri) ?: "upload" - val dir = File(context.cacheDir, "uploads").apply { mkdirs() } - val target = File(dir, "${System.currentTimeMillis()}${extForMime(mime)}") - context.contentResolver.openInputStream(uri)!!.use { input -> - target.outputStream().use { output -> input.copyTo(output) } + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + onPicked(null) + return@rememberLauncherForActivityResult + } + try { + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + val name = queryDisplayName(context, uri) ?: "upload" + val dir = File(context.cacheDir, "uploads").apply { mkdirs() } + val target = File(dir, "${System.currentTimeMillis()}${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) } - onPicked(PickedFile(target.absolutePath, name, mime, target.length())) - } catch (e: Exception) { - onPicked(null) } - } Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Button(onClick = { launcher.launch("*/*") }) { Text("Pick a file") } } @@ -85,29 +86,33 @@ 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) } + 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) } - 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? { +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) if (idx >= 0 && c.moveToFirst()) return c.getString(idx) @@ -119,59 +124,99 @@ actual fun mediaCacheBaseDir(): String = AndroidEnv.context.externalCacheDir?.absolutePath ?: AndroidEnv.context.cacheDir.absolutePath -actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) { - try { - BitmapFactory.decodeFile(path)?.asImageBitmap() - } catch (e: Exception) { - null +actual suspend fun loadMediaImage(path: String): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + BitmapFactory.decodeFile(path)?.asImageBitmap() + } catch (e: Exception) { + null + } } -} -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 +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 + } } -} -actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) { - try { - 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 } - val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null - val stream = java.io.ByteArrayOutputStream() - bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) - bitmap.recycle() - android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP) - } catch (e: Exception) { - null +actual suspend fun decodeImageBytes( + bytes: ByteArray, + maxSize: Int, +): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, 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.decodeByteArray(bytes, 0, bytes.size, opts)?.asImageBitmap() + } catch (e: Exception) { + null + } } -} -actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) { +actual fun readBackdropBytes(id: String): ByteArray? = try { - val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() + AndroidEnv.context.assets + .open("backdrops/$id.jpg") + .use { it.readBytes() } } catch (e: Exception) { null } -} + +actual suspend fun encodeImageAsBase64( + path: String, + maxSize: Int, +): String? = + withContext(Dispatchers.IO) { + try { + 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 } + val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null + val stream = java.io.ByteArrayOutputStream() + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) + bitmap.recycle() + android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP) + } catch (e: Exception) { + null + } + } + +actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() + } catch (e: Exception) { + null + } + } @Composable -actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { +actual fun MediaPlayerView( + media: MediaItem, + modifier: Modifier, +) { val path = media.localPath if (path == null) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -191,7 +236,10 @@ actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { } @Composable -private fun VideoPlayerView(path: String, modifier: Modifier) { +private fun VideoPlayerView( + path: String, + modifier: Modifier, +) { AndroidView( factory = { ctx -> PlayerView(ctx).apply { @@ -205,21 +253,27 @@ private fun VideoPlayerView(path: String, modifier: Modifier) { }, update = {}, onRelease = { view -> view.player?.release() }, - modifier = modifier - .fillMaxWidth() - .height(220.dp), + modifier = + modifier + .fillMaxWidth() + .height(220.dp), ) } @Composable -private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) { +private fun AudioPlayerView( + path: String, + filename: String, + modifier: Modifier, +) { val context = LocalContext.current - val player = remember { - ExoPlayer.Builder(context).build().apply { - setMediaItem(M3MediaItem.fromUri(path.toUri())) - prepare() + val player = + remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(M3MediaItem.fromUri(path.toUri())) + prepare() + } } - } DisposableEffect(player) { onDispose { player.release() } } var playing by remember { mutableStateOf(false) } @@ -237,9 +291,10 @@ private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) } Row( - modifier = modifier - .fillMaxWidth() - .padding(8.dp), + modifier = + modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = { @@ -270,17 +325,21 @@ private fun fmt(seconds: Float): String { return "%d:%02d".format(s / 60, s % 60) } -actual fun openDocument(path: String, mime: String) { +actual fun openDocument( + path: String, + mime: String, +) { val context = AndroidEnv.context try { val file = File(path) val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, mime) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } + val intent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mime) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } context.startActivity(intent) } catch (e: Exception) { - Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show() } -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 42474cb..32b4582 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -6,6 +6,8 @@ import android.os.Build import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import iris.data.SecureStore +import iris.ui.theme.Backdrop +import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme import java.util.UUID @@ -15,18 +17,22 @@ import java.util.UUID * values are migrated on first run (read old key, write encrypted, delete * old key) so an upgrade never loses the pairing. */ -class AndroidSecureStore(context: Context) : SecureStore { +class AndroidSecureStore( + context: Context, +) : SecureStore { private val appContext = context.applicationContext private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE) - private val prefs: SharedPreferences = EncryptedSharedPreferences.create( - appContext, - SECURE_PREFS_NAME, - MasterKey.Builder(appContext) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + private val prefs: SharedPreferences = + EncryptedSharedPreferences.create( + appContext, + SECURE_PREFS_NAME, + MasterKey + .Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) init { migratePlainValues() @@ -50,7 +56,8 @@ class AndroidSecureStore(context: Context) : SecureStore { } if (migrated) editor.apply() // The plain store must not keep a copy of any value. - plainPrefs.edit() + plainPrefs + .edit() .remove(KEY_URL) .remove(KEY_TOKEN) .remove(KEY_DEVICE_ID) @@ -73,7 +80,12 @@ class AndroidSecureStore(context: Context) : SecureStore { get() { var id = prefs.getString(KEY_DEVICE_ID, null) if (id.isNullOrBlank()) { - id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16) + id = "dev_" + + UUID + .randomUUID() + .toString() + .replace("-", "") + .take(16) prefs.edit().putString(KEY_DEVICE_ID, id).apply() } return id @@ -123,7 +135,11 @@ class AndroidSecureStore(context: Context) : SecureStore { 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" } + get() = + prefs + .getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase()) + .orEmpty() + .ifBlank { BackgroundMode.Image.name.lowercase() } set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply() override var backgroundColor: Int @@ -131,20 +147,31 @@ class AndroidSecureStore(context: Context) : SecureStore { set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply() override var backgroundImagePath: String - get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty() + get() = + prefs + .getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path) + .orEmpty() + .ifBlank { Backdrop.DEFAULT.path } set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply() override var fontSizeScale: Float get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f) set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply() - override fun savePairing(url: String, token: String) { + override fun savePairing( + url: String, + token: String, + ) { serverUrl = url this.token = token } override fun clear() { - prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply() + prefs + .edit() + .remove(KEY_URL) + .remove(KEY_TOKEN) + .apply() } private companion object { @@ -168,4 +195,4 @@ class AndroidSecureStore(context: Context) : SecureStore { const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path" const val KEY_FONT_SIZE_SCALE = "font_size_scale" } -} \ No newline at end of file +} diff --git a/app/shared/src/androidMain/kotlin/iris/platform/Platform.kt b/app/shared/src/androidMain/kotlin/iris/platform/Platform.kt index d8b8dfd..90e1282 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/Platform.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/Platform.kt @@ -1,3 +1,3 @@ package iris.platform -actual val isDesktop: Boolean = false \ No newline at end of file +actual val isDesktop: Boolean = false diff --git a/app/shared/src/commonMain/kotlin/iris/platform/Platform.kt b/app/shared/src/commonMain/kotlin/iris/platform/Platform.kt index 2506f06..5a0fb21 100644 --- a/app/shared/src/commonMain/kotlin/iris/platform/Platform.kt +++ b/app/shared/src/commonMain/kotlin/iris/platform/Platform.kt @@ -1,4 +1,4 @@ package iris.platform /** True on the desktop target (M6: big-screen layout, shortcuts, tray). */ -expect val isDesktop: Boolean \ No newline at end of file +expect val isDesktop: Boolean diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt index 654b3a1..0bc9174 100644 --- a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt @@ -35,21 +35,49 @@ expect fun mediaCacheBaseDir(): String 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? +expect suspend fun loadScaledImage( + path: String, + maxSize: Int, +): ImageBitmap? + +/** + * Decode an image from raw bytes (bundled backdrops), scaled to at most + * [maxSize] px on the longest edge. Null on failure. + */ +expect suspend fun decodeImageBytes( + bytes: ByteArray, + maxSize: Int, +): ImageBitmap? + +/** + * Read a bundled backdrop image (shipped in the app) by its [id] (the file + * name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it + * from `assets/backdrops/`, desktop from the classpath. Null on failure. + */ +expect fun readBackdropBytes(id: String): ByteArray? /** * Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on * the longest edge. Null on failure. Used for channel icons (stored on the * gateway, synced across devices). */ -expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? +expect suspend fun encodeImageAsBase64( + path: String, + maxSize: Int, +): String? /** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */ expect suspend fun decodeBase64Image(base64: String): ImageBitmap? /** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */ @Composable -expect fun MediaPlayerView(media: MediaItem, modifier: Modifier) +expect fun MediaPlayerView( + media: MediaItem, + modifier: Modifier, +) /** Open a document with the system handler (best effort). */ -expect fun openDocument(path: String, mime: String) \ No newline at end of file +expect fun openDocument( + path: String, + mime: String, +) diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index fcc5656..c552cc9 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -1,7 +1,7 @@ package iris.state -import iris.data.ChatStore import iris.data.ChannelStore +import iris.data.ChatStore import iris.data.MediaItem import iris.data.MessageItem import iris.data.MsgStatus @@ -24,22 +24,21 @@ import iris.protocol.MessageDeletedPayload import iris.protocol.MessagePayload import iris.protocol.MessageStopPayload import iris.protocol.NotificationPayload -import iris.protocol.ReadReceiptPayload import iris.protocol.ROLE_ASSISTANT +import iris.protocol.ReadReceiptPayload import iris.protocol.SearchHit import iris.protocol.SearchResultsPayload import iris.protocol.SlashCommand import iris.protocol.StatusPayload import iris.protocol.SyncDonePayload -import iris.protocol.TYPE_ERROR -import iris.protocol.TYPE_HISTORY -import iris.protocol.TYPE_NOTIFICATION import iris.protocol.TYPE_CHANNEL_CREATED import iris.protocol.TYPE_CHANNEL_DELETED import iris.protocol.TYPE_CHANNEL_LIST import iris.protocol.TYPE_CHANNEL_RENAMED import iris.protocol.TYPE_COMMANDS_CATALOG import iris.protocol.TYPE_COMMENTARY +import iris.protocol.TYPE_ERROR +import iris.protocol.TYPE_HISTORY import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE_DELETE @@ -47,6 +46,7 @@ import iris.protocol.TYPE_MESSAGE_DELETED import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_UPDATE +import iris.protocol.TYPE_NOTIFICATION import iris.protocol.TYPE_READ_RECEIPT import iris.protocol.TYPE_SEARCH_RESULTS import iris.protocol.TYPE_STATUS @@ -69,6 +69,7 @@ import iris.protocol.historyFrame import iris.protocol.messageDeleteFrame import iris.protocol.searchFrame import iris.protocol.syncFrame +import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme import kotlinx.coroutines.CoroutineScope @@ -105,11 +106,12 @@ class IrisController( val toolDetail: StateFlow = _toolDetail.asStateFlow() fun cycleToolDetail() { - _toolDetail.value = when (_toolDetail.value) { - ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED - ToolDetail.TRUNCATED -> ToolDetail.NOTHING - ToolDetail.NOTHING -> ToolDetail.EVERYTHING - } + _toolDetail.value = + when (_toolDetail.value) { + ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED + ToolDetail.TRUNCATED -> ToolDetail.NOTHING + ToolDetail.NOTHING -> ToolDetail.EVERYTHING + } store.toolDetail = _toolDetail.value.name.lowercase() } @@ -170,17 +172,19 @@ class IrisController( // ── 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 }, + 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) } @@ -189,13 +193,25 @@ class IrisController( fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) } - fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) } + fun setBackgroundMode(mode: BackgroundMode) = + updateTheme { + // Switching to image mode without an image falls back to the default + // bundled backdrop instead of silently showing the solid color. + it.copy( + backgroundMode = mode, + backgroundImagePath = + if (mode == BackgroundMode.Image && it.backgroundImagePath.isNullOrBlank()) { + Backdrop.DEFAULT.path + } else { + it.backgroundImagePath + }, + ) + } 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 removeBackgroundImage() = updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) } fun resetTheme() = updateTheme { UserTheme() } @@ -261,6 +277,7 @@ class IrisController( val attachments: StateFlow> = _attachments.asStateFlow() // ── M5: notification banners (docs/08 §8.3) ─────────────────────────── + /** An in-app notification banner shown above the composer. */ data class Banner( val id: Long, @@ -280,7 +297,13 @@ class IrisController( _banners.value = _banners.value.filterNot { it.id == id } } - private fun pushBanner(kind: String, title: String, body: String, chatId: String?, threadId: String?) { + private fun pushBanner( + kind: String, + title: String, + body: String, + chatId: String?, + threadId: String?, + ) { val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent) _banners.value = (_banners.value + banner).takeLast(5) @@ -299,7 +322,11 @@ class IrisController( * the chat (deep link). The same per-chat notification id is reused, so a * multi-segment turn updates one notification instead of stacking. */ - private fun notifyMessageIfBackgrounded(chatId: String?, threadId: String?, text: String) { + private fun notifyMessageIfBackgrounded( + chatId: String?, + threadId: String?, + text: String, + ) { if (isAppForeground()) return if (text.isBlank()) return val id = chatId ?: "android:default" @@ -308,7 +335,10 @@ class IrisController( } /** Single-line preview for a notification body (lock-screen privacy). */ - private fun preview(text: String, limit: Int = 120): String { + private fun preview( + text: String, + limit: Int = 120, + ): String { val flat = text.replace('\n', ' ').replace('\t', ' ').trim() return if (flat.length <= limit) flat else flat.take(limit).trimEnd() + "…" } @@ -318,7 +348,10 @@ class IrisController( /** Open the chat referenced by a push notification tap. Applied now if * connected, otherwise on the next hello.ack (lane may not exist yet). */ - fun openDeepLink(chatId: String?, threadId: String?) { + fun openDeepLink( + chatId: String?, + threadId: String?, + ) { if (chatId.isNullOrBlank()) return pendingDeepLink = chatId to threadId if (client.state.value is GatewayClient.State.Connected) applyDeepLink() @@ -343,7 +376,8 @@ class IrisController( TYPE_TOOL_PROGRESS, TYPE_TOOL_END, TYPE_COMMENTARY, - TYPE_MEDIA_OFFER -> { + TYPE_MEDIA_OFFER, + -> { chat.onFrame(frame) // M4: pull offered media into the local cache. if (frame.type == TYPE_MEDIA_OFFER) { @@ -356,23 +390,31 @@ class IrisController( // message.stop; non-streaming replies are a single // assistant `message`. when (frame.type) { - TYPE_MESSAGE_STOP -> + TYPE_MESSAGE_STOP -> { frame.payloadAs()?.let { notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) } - TYPE_MESSAGE -> + } + + TYPE_MESSAGE -> { frame.payloadAs()?.let { if (it.role == ROLE_ASSISTANT) { notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) } } - else -> Unit + } + + else -> { + Unit + } } } + TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED, TYPE_CHANNEL_DELETED, - TYPE_CHANNEL_LIST -> { + TYPE_CHANNEL_LIST, + -> { channels.onFrame(frame) // Auto-created thread (Settings → "Threads"): the // gateway minted it for a message in the flat lane we @@ -396,26 +438,37 @@ class IrisController( frame.payloadAs()?.let { p -> val (curChat, curThread) = chat.parseLane(chat.currentLane.value) when { - curThread == p.chatId -> openChannel(curChat) - curChat == p.chatId -> + curThread == p.chatId -> { + openChannel(curChat) + } + + curChat == p.chatId -> { channels.defaultChannel()?.let { openChannel(it.chatId) } - else -> Unit + } + + else -> { + Unit + } } } } } + TYPE_SEARCH_RESULTS -> { frame.payloadAs()?.let { _searchResults.value = it.hits } _searching.value = false } + TYPE_COMMANDS_CATALOG -> { frame.payloadAs()?.let { _slashCommands.value = it.commands } } + TYPE_SYNC_DONE -> { // Replayed frames already flowed through [events]; the // cursor is authoritative server-side (outbox). frame.payloadAs()?.let { store.syncCursor = it.cursor } } + TYPE_HISTORY -> { // Full message history for a chat/thread (initial open / // restart restore). `sync` only replays the outbox delta @@ -432,6 +485,7 @@ class IrisController( ) } } + TYPE_NOTIFICATION -> { frame.payloadAs()?.let { p -> pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) @@ -444,12 +498,15 @@ class IrisController( } } } + TYPE_TYPING -> { frame.payloadAs()?.let { _typing.value = it.on } } + TYPE_READ_RECEIPT -> { frame.payloadAs()?.let { chat.markRead(it.messageId) } } + TYPE_MESSAGE_DELETED -> { // A message was deleted (by this or another device). // Idempotent: dropping an unknown id is a no-op. @@ -457,16 +514,21 @@ class IrisController( chat.removeMessages(it.messageIds.toSet()) } } + TYPE_STATUS -> { frame.payloadAs()?.let { _gatewayStatus.value = it.state } } + TYPE_ERROR -> { // M7: error frames are global, not per-message — fail // any optimistic sends still in flight so they don't // sit at "sending…" forever. chat.failPending() } - else -> Unit + + else -> { + Unit + } } } } @@ -537,7 +599,10 @@ class IrisController( /** Switch to a specific thread lane under [chatId]. Loads the full history * on first open this session. */ - fun openThread(chatId: String, threadId: String) { + fun openThread( + chatId: String, + threadId: String, + ) { chat.setLane(chat.laneKey(chatId, threadId)) loadHistory(chatId, threadId) } @@ -550,13 +615,19 @@ class IrisController( client.sendFrame(channelCreateFrame(0, trimmed)) } - fun createThread(name: String, parentChatId: String) { + fun createThread( + name: String, + parentChatId: String, + ) { val trimmed = name.trim() if (trimmed.isEmpty()) return client.sendFrame(channelCreateFrame(0, trimmed, kind = "thread", parentChatId = parentChatId)) } - fun renameChannel(chatId: String, name: String) { + fun renameChannel( + chatId: String, + name: String, + ) { val trimmed = name.trim() if (trimmed.isEmpty()) return client.sendFrame(channelRenameFrame(0, chatId, trimmed)) @@ -567,19 +638,29 @@ class IrisController( } /** Toggle the cosmetic favorite flag (sorts the channel to the top). */ - fun setChannelFavorite(chatId: String, on: Boolean) { + fun setChannelFavorite( + chatId: String, + on: Boolean, + ) { client.sendFrame(channelFavoriteFrame(0, chatId, on)) } /** Mark the channel as automation (read-only; cron/webhook output only). * The default channel cannot be marked (the server enforces this too). */ - fun setChannelAutomation(chatId: String, on: Boolean) { + fun setChannelAutomation( + chatId: String, + on: Boolean, + ) { client.sendFrame(channelSetAutomationFrame(0, chatId, on)) } /** Set the channel's cosmetic icon (base64 image) and/or avatar color. * Pass null to clear a field. */ - fun setChannelIcon(chatId: String, icon: String?, color: String?) { + fun setChannelIcon( + chatId: String, + icon: String?, + color: String?, + ) { client.sendFrame(channelIconFrame(0, chatId, icon, color)) } @@ -593,7 +674,12 @@ class IrisController( // ── M3: search ──────────────────────────────────────────────────────── - fun search(query: String, scope: String = "all", chatId: String? = null, threadId: String? = null) { + fun search( + query: String, + scope: String = "all", + chatId: String? = null, + threadId: String? = null, + ) { val q = query.trim() if (q.isEmpty()) return _lastQuery.value = q @@ -626,7 +712,10 @@ class IrisController( * Populates the lane via the `history` response; used on first open of a * chat and after a process death (restart) to restore the view, since the * `sync` delta does not cover messages older than the saved cursor. */ - fun loadHistory(chatId: String, threadId: String? = null) { + fun loadHistory( + chatId: String, + threadId: String? = null, + ) { val lane = chat.laneKey(chatId, threadId) if (lane in historyLoaded) return historyLoaded.add(lane) @@ -636,20 +725,27 @@ class IrisController( } /** Optimistic send: show immediately in the current lane, then hand to the gateway. - * M4: [attachments] (uploaded) are attached via media_refs. */ - fun send(text: String, attachments: List = emptyList()) { + * M4: [attachments] (uploaded) are attached via media_refs. */ + fun send( + text: String, + attachments: List = emptyList(), + ) { val trimmed = text.trim() val refs = attachments.mapNotNull { it.mediaRef } if (trimmed.isEmpty() && refs.isEmpty()) return val lane = chat.currentLane.value val (chatId, threadId) = chat.parseLane(lane) - val media = attachments.map { - MediaItem( - mediaId = it.mediaRef ?: it.filename, - kind = it.kind, mime = it.mime, size = it.size, filename = it.filename, - localPath = it.path, - ) - } + val media = + attachments.map { + MediaItem( + mediaId = it.mediaRef ?: it.filename, + kind = it.kind, + mime = it.mime, + size = it.size, + filename = it.filename, + localPath = it.path, + ) + } chat.addPending(trimmed, lane, media) client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId)) _attachments.value = emptyList() @@ -660,7 +756,11 @@ class IrisController( * gateway (Telegram topic-mode workflow). Threading is only active on * the default channel; other channels stay flat. Slash commands and * media-only sends stay in the flat lane. */ - private fun wantsAutoThread(text: String, threadId: String?, chatId: String): Boolean = + private fun wantsAutoThread( + text: String, + threadId: String?, + chatId: String, + ): Boolean = _threadsEnabled.value && threadId == null && text.isNotEmpty() && @@ -700,28 +800,36 @@ class IrisController( /** Stage a picked file: upload it, then keep it as a pending attachment. */ fun attachFile(picked: PickedFile) { val kind = kindFromMime(picked.mime) - val placeholder = PendingAttachment( - filename = picked.name, mime = picked.mime, size = picked.size, kind = kind, - path = picked.path, - ) + val placeholder = + PendingAttachment( + filename = picked.name, + mime = picked.mime, + size = picked.size, + kind = kind, + path = picked.path, + ) _attachments.value = _attachments.value + placeholder scope.launch { val mediaRef = "up_${Random.nextLong(1_000_000_000L, 9_999_999_999L)}" - val result = client.uploadMedia( - path = picked.path, - mime = picked.mime, - kind = kind, - filename = picked.name, - mediaRef = mediaRef, - ) - _attachments.value = _attachments.value.map { - if (it.filename == picked.name && it.uploading) { - result.fold( - { ref -> it.copy(uploading = false, mediaRef = ref) }, - { e -> it.copy(uploading = false, error = e.message) }, - ) - } else it - } + val result = + client.uploadMedia( + path = picked.path, + mime = picked.mime, + kind = kind, + filename = picked.name, + mediaRef = mediaRef, + ) + _attachments.value = + _attachments.value.map { + if (it.filename == picked.name && it.uploading) { + result.fold( + { ref -> it.copy(uploading = false, mediaRef = ref) }, + { e -> it.copy(uploading = false, error = e.message) }, + ) + } else { + it + } + } } } @@ -748,7 +856,10 @@ class IrisController( } /** Connect-screen action: real hello test, then save + (re)connect. */ - suspend fun connect(url: String, token: String): Result { + suspend fun connect( + url: String, + token: String, + ): Result { val test = client.testHello(url, token) if (test.isFailure) return test store.savePairing(url, token) @@ -779,9 +890,10 @@ private fun HistoryMessage.toMessageItem(): MessageItem = reasoning = reasoning, model = model, tokens = tokens, - media = media.map { - MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename) - }, + media = + media.map { + MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename) + }, ) // Gateway restart routine (hermes, same icon + wording on all platforms). The @@ -797,14 +909,13 @@ private const val GATEWAY_ONLINE_MSG = /** How much tool detail to show (Settings → "Tool detail"). */ enum class ToolDetail { - EVERYTHING, // name + full args (collapsible) + output preview - TRUNCATED, // one-liner "emoji name: preview", collapsible (default) - NOTHING, // suppress tool cards entirely + EVERYTHING, // name + full args (collapsible) + output preview + TRUNCATED, // one-liner "emoji name: preview", collapsible (default) + NOTHING, // suppress tool cards entirely ; companion object { /** Parse a persisted value; unknown/blank falls back to the default. */ - fun fromName(name: String): ToolDetail = - entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED + fun fromName(name: String): ToolDetail = entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED } -} \ No newline at end of file +} 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 aa56925..f1f56da 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -31,8 +33,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -72,8 +72,8 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -268,14 +268,18 @@ fun ChatScreen(controller: IrisController) { val slashCommands by controller.slashCommands.collectAsState() val slashQuery = if (input.startsWith("/") && !input.contains(" ")) input.removePrefix("/") else null - val slashMatches = remember(slashQuery, slashCommands) { - if (slashQuery == null) emptyList() - else slashCommands - .map { it to bestSlashScore(slashQuery, it) } - .filter { it.second >= 0 } - .sortedWith(compareBy({ it.second }, { it.first.name })) - .map { it.first } - } + val slashMatches = + remember(slashQuery, slashCommands) { + if (slashQuery == null) { + emptyList() + } else { + slashCommands + .map { it to bestSlashScore(slashQuery, it) } + .filter { it.second >= 0 } + .sortedWith(compareBy({ it.second }, { it.first.name })) + .map { it.first } + } + } val slashDrawerOpen = slashQuery != null && slashMatches.isNotEmpty() // The catalog is fetched on connect; if the drawer opens before the first @@ -292,46 +296,71 @@ fun ChatScreen(controller: IrisController) { } // M6: keyboard shortcuts (docs/11 §11.3) — desktop only. - val shortcutsModifier = if (isDesktop) { - Modifier.onPreviewKeyEvent { e -> - if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed - val overlayOpen = - showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings - when { - ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true } - // Threads are only active on the default channel. - ctrlOrMeta && e.key == Key.T -> { - if (currentChannel?.isDefault == true) showNewThread = true - true + val shortcutsModifier = + if (isDesktop) { + Modifier.onPreviewKeyEvent { e -> + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed + val overlayOpen = + showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings + when { + ctrlOrMeta && e.key == Key.N -> { + showNewChannel = true + true + } + + // Threads are only active on the default channel. + ctrlOrMeta && e.key == Key.T -> { + if (currentChannel?.isDefault == true) showNewThread = true + true + } + + ctrlOrMeta && e.key == Key.F -> { + showSearch = true + true + } + + ctrlOrMeta && e.key == Key.K -> { + showPalette = true + true + } + + // Escape closes the slash drawer (clears the "/" input). + e.key == Key.Escape && slashDrawerOpen -> { + input = "" + true + } + + e.key == Key.Escape && overlayOpen -> { + showSearch = false + showPicker = false + showPalette = false + showNewChannel = false + showNewThread = false + showSettings = false + true + } + + e.key == Key.Slash && !composerFocused && !overlayOpen -> { + composerFocusRequester.requestFocus() + true + } + + else -> { + false + } } - ctrlOrMeta && e.key == Key.F -> { showSearch = true; true } - ctrlOrMeta && e.key == Key.K -> { showPalette = true; true } - // Escape closes the slash drawer (clears the "/" input). - e.key == Key.Escape && slashDrawerOpen -> { input = ""; true } - e.key == Key.Escape && overlayOpen -> { - showSearch = false - showPicker = false - showPalette = false - showNewChannel = false - showNewThread = false - showSettings = false - true - } - e.key == Key.Slash && !composerFocused && !overlayOpen -> { - composerFocusRequester.requestFocus() - true - } - else -> false } + } else { + Modifier } - } else { - Modifier - } // M6: the chat column (header + list + composer), shared by both layouts. @Composable - fun ChatContent(showHamburger: Boolean, onOpenDrawer: () -> Unit) { + fun ChatContent( + showHamburger: Boolean, + onOpenDrawer: () -> Unit, + ) { // Topic context menu (long-press / right-click): rename / delete a thread. var renameThread by remember { mutableStateOf(null) } var deleteThread by remember { mutableStateOf(null) } @@ -372,9 +401,10 @@ fun ChatScreen(controller: IrisController) { // Header: title pill on the left, status bubble + search on the // right, single row on both wide and narrow layouts. Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { if (showHamburger) { @@ -396,12 +426,12 @@ fun ChatScreen(controller: IrisController) { if (isDesktop) { IconButton(onClick = { showInspector = !showInspector }) { Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background( - if (showInspector) IrisColors.chipSelected else Color.Transparent, - ) - .padding(2.dp), + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + if (showInspector) IrisColors.chipSelected else Color.Transparent, + ).padding(2.dp), ) { Text("ⓘ", fontSize = 16.sp) } @@ -445,34 +475,45 @@ fun ChatScreen(controller: IrisController) { } items(rows, key = { it.key }) { row -> when (row) { - is ChatRow.Day -> DaySeparator(row.label) - is ChatRow.Item -> when (val item = row.item) { - is MessageItem -> { - if (item.isSystem) { - SystemMessage(item) - } else { - // Only finalized messages are selectable: a - // streaming bubble has no final id yet and a - // pending echo isn't on the server to delete. - val selectable = !item.streaming && !item.pending - MessageBubble( - msg = item, - maxWidth = bubbleMaxWidth, - reasoningAutoCollapse = reasoningAutoCollapse, - onRetry = { controller.retrySend(item.id) }, - selectionMode = selectionMode && selectable, - selected = selectable && item.id in selectedIds, - onToggleSelect = { if (selectable) toggleSelect(item.id) }, - onLongPress = { - if (selectable) { - if (selectionMode) toggleSelect(item.id) - else enterSelection(item.id) - } - }, - ) + is ChatRow.Day -> { + DaySeparator(row.label) + } + + is ChatRow.Item -> { + when (val item = row.item) { + is MessageItem -> { + if (item.isSystem) { + SystemMessage(item) + } else { + // Only finalized messages are selectable: a + // streaming bubble has no final id yet and a + // pending echo isn't on the server to delete. + val selectable = !item.streaming && !item.pending + MessageBubble( + msg = item, + maxWidth = bubbleMaxWidth, + reasoningAutoCollapse = reasoningAutoCollapse, + onRetry = { controller.retrySend(item.id) }, + selectionMode = selectionMode && selectable, + selected = selectable && item.id in selectedIds, + onToggleSelect = { if (selectable) toggleSelect(item.id) }, + onLongPress = { + if (selectable) { + if (selectionMode) { + toggleSelect(item.id) + } else { + enterSelection(item.id) + } + } + }, + ) + } + } + + is ToolItem -> { + if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail) } } - is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail) } } } @@ -495,20 +536,21 @@ fun ChatScreen(controller: IrisController) { val alpha = remember { Animatable(0f) } LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) } Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 12.dp) - .alpha(alpha.value) - .size(40.dp) - .clip(CircleShape) - .background(IrisColors.surface) - .border(1.dp, IrisColors.divider, CircleShape) - .clickable { - val last = listState.layoutInfo.totalItemsCount - 1 - if (last >= 0) { - drawerScope.launch { listState.animateScrollToItem(last) } - } - }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 12.dp) + .alpha(alpha.value) + .size(40.dp) + .clip(CircleShape) + .background(IrisColors.surface) + .border(1.dp, IrisColors.divider, CircleShape) + .clickable { + val last = listState.layoutInfo.totalItemsCount - 1 + if (last >= 0) { + drawerScope.launch { listState.animateScrollToItem(last) } + } + }, contentAlignment = Alignment.Center, ) { Icon( @@ -524,10 +566,11 @@ fun ChatScreen(controller: IrisController) { // Pending attachments (M4; hidden in automation channels — no composer) if (attachments.isNotEmpty() && !isAutomation) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { attachments.forEach { att -> @@ -539,9 +582,10 @@ fun ChatScreen(controller: IrisController) { // M5: notification banners (above the composer) if (banners.isNotEmpty()) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { banners.forEach { b -> @@ -559,28 +603,42 @@ fun ChatScreen(controller: IrisController) { // M7: connection-state banner (persistent, above the composer; no dismiss). val gatewayStatus by controller.gatewayStatus.collectAsState() - val connBanner = when { - state is GatewayClient.State.Connecting -> - "Connecting to gateway…" - state is GatewayClient.State.Reconnecting -> - "Reconnecting to gateway… messages will sync automatically when the connection is back." - gatewayStatus == "restarting" -> "Gateway is restarting…" - gatewayStatus == "degraded" -> - "Gateway reports a degraded state — replies may be slow or unavailable." - else -> null - } + val connBanner = + when { + state is GatewayClient.State.Connecting -> { + "Connecting to gateway…" + } + + state is GatewayClient.State.Reconnecting -> { + "Reconnecting to gateway… messages will sync automatically when the connection is back." + } + + gatewayStatus == "restarting" -> { + "Gateway is restarting…" + } + + gatewayStatus == "degraded" -> { + "Gateway reports a degraded state — replies may be slow or unavailable." + } + + else -> { + null + } + } if (connBanner != null) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(IrisColors.panel) - .padding(horizontal = 14.dp, vertical = 8.dp), + modifier = + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .padding(horizontal = 14.dp, vertical = 8.dp), ) { Text(connBanner, fontSize = 12.sp, color = IrisColors.textTertiary) } @@ -596,27 +654,30 @@ fun ChatScreen(controller: IrisController) { exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 }, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(RoundedCornerShape(16.dp)) - .background(IrisColors.panel) - .border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)), ) { slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd -> if (index > 0) { Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(IrisColors.divider), + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(IrisColors.divider), ) } Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSlashPick(cmd) } - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { onSlashPick(cmd) } + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -668,16 +729,18 @@ fun ChatScreen(controller: IrisController) { if (isAutomation) { // Read-only notice in place of the composer. Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(IrisColors.panel) - .padding(horizontal = 14.dp, vertical = 8.dp), + modifier = + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .padding(horizontal = 14.dp, vertical = 8.dp), ) { Text( "⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.", @@ -686,64 +749,70 @@ fun ChatScreen(controller: IrisController) { ) } } - } else if (!selectionMode) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .clip(RoundedCornerShape(28.dp)) - .background(IrisColors.surface) - .padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + } else if (!selectionMode) { Row( - modifier = Modifier - .weight(1f) - .padding(horizontal = 6.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .clip(RoundedCornerShape(28.dp)) + .background(IrisColors.surface) + .padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified) - .focusRequester(composerFocusRequester) - .onFocusChanged { composerFocused = it.isFocused }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { doSend() }), - onTextLayout = { result -> - if (result.lineCount > 0) { - val totalHeight = result.getLineBottom(result.lineCount - 1) - val lineHeight = totalHeight / result.lineCount - textHeightPx = minOf(totalHeight, lineHeight * 5f) - } - }, - ) - if (input.isEmpty()) { - Text( - "Message", - color = IrisColors.textDim, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.TopStart), + Row( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 6.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + BasicTextField( + value = input, + onValueChange = { input = it }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified) + .focusRequester(composerFocusRequester) + .onFocusChanged { composerFocused = it.isFocused }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { doSend() }), + onTextLayout = { result -> + if (result.lineCount > 0) { + val totalHeight = result.getLineBottom(result.lineCount - 1) + val lineHeight = totalHeight / result.lineCount + textHeightPx = minOf(totalHeight, lineHeight * 5f) + } + }, ) + if (input.isEmpty()) { + Text( + "Message", + color = IrisColors.textDim, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.align(Alignment.TopStart), + ) + } + } + IconButton(onClick = { showPicker = true }) { + Text("📎", fontSize = 18.sp) } } - IconButton(onClick = { showPicker = true }) { - Text("📎", fontSize = 18.sp) + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f)) + .clickable(enabled = canSend) { doSend() }, + contentAlignment = Alignment.Center, + ) { + Text("➤", color = Color.White, fontSize = 16.sp) } } - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f)) - .clickable(enabled = canSend) { doSend() }, - contentAlignment = Alignment.Center, - ) { - Text("➤", color = Color.White, fontSize = 16.sp) - } } // Topic context menu: rename a thread. @@ -786,8 +855,11 @@ fun ChatScreen(controller: IrisController) { title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") }, text = { Text( - if (n == 1) "This message will be deleted for all devices." - else "These messages will be deleted for all devices.", + if (n == 1) { + "This message will be deleted for all devices." + } else { + "These messages will be deleted for all devices." + }, ) }, confirmButton = { @@ -913,8 +985,11 @@ fun ChatScreen(controller: IrisController) { currentChatId = currentChatId, currentThreadId = currentThreadId, onJump = { hit -> - if (hit.threadId.isNullOrBlank()) controller.openChannel(hit.chatId) - else controller.openThread(hit.chatId, hit.threadId) + if (hit.threadId.isNullOrBlank()) { + controller.openChannel(hit.chatId) + } else { + controller.openThread(hit.chatId, hit.threadId) + } showSearch = false }, onDismiss = { showSearch = false }, @@ -1023,15 +1098,16 @@ fun ChatScreen(controller: IrisController) { // M6: command palette (Ctrl/Cmd+K) if (showPalette) { CommandPalette( - actions = listOf( - "new-channel" to "New channel (Ctrl+N)", - "new-thread" to "New topic (Ctrl+T)", - "search" to "Search (Ctrl+F)", - "toggle-threads" to "Toggle threads", - "toggle-reasoning-collapse" to "Toggle reasoning auto-collapse", - "cycle-tool-detail" to "Cycle tool verbosity", - "toggle-inspector" to "Toggle inspector", - ), + actions = + listOf( + "new-channel" to "New channel (Ctrl+N)", + "new-thread" to "New topic (Ctrl+T)", + "search" to "Search (Ctrl+F)", + "toggle-threads" to "Toggle threads", + "toggle-reasoning-collapse" to "Toggle reasoning auto-collapse", + "cycle-tool-detail" to "Cycle tool verbosity", + "toggle-inspector" to "Toggle inspector", + ), onAction = { id -> when (id) { "new-channel" -> showNewChannel = true @@ -1053,11 +1129,16 @@ fun ChatScreen(controller: IrisController) { private sealed interface ChatRow { val key: String - data class Item(val item: ChatItem) : ChatRow { + data class Item( + val item: ChatItem, + ) : ChatRow { override val key: String get() = item.id } - data class Day(val day: String, val label: String) : ChatRow { + data class Day( + val day: String, + val label: String, + ) : ChatRow { override val key: String get() = "day_$day" } } @@ -1085,10 +1166,11 @@ private fun buildChatRows(items: List): List { private fun DaySeparator(label: String) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(IrisColors.panel) - .padding(horizontal = 12.dp, vertical = 4.dp), + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(IrisColors.panel) + .padding(horizontal = 12.dp, vertical = 4.dp), ) { Text(label, fontSize = 12.sp, color = IrisColors.textSecondary) } @@ -1102,10 +1184,11 @@ private fun DaySeparator(label: String) { private fun SystemMessage(msg: MessageItem) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(IrisColors.panel) - .padding(horizontal = 12.dp, vertical = 6.dp), + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(IrisColors.panel) + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text(msg.text, fontSize = 12.sp, color = IrisColors.textSecondary) } @@ -1126,21 +1209,24 @@ private fun TitlePill( val chatId = channel?.chatId val isAutomation = channel?.automation == true Box( - modifier = modifier - .clip(RoundedCornerShape(16.dp)) - .background(IrisColors.surface) - .then( - if (isAutomation) { - Modifier.clickable { chatId?.let(onCopyChatId) } - } else { - Modifier - } - ) - .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + modifier = + modifier + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.surface) + .then( + if (isAutomation) { + Modifier.clickable { chatId?.let(onCopyChatId) } + } else { + Modifier + }, + ).padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { - if (channel != null) ChannelAvatar(channel, 34.dp) - else LetterAvatar(channelName, 34.dp, IrisColors.primary) + if (channel != null) { + ChannelAvatar(channel, 34.dp) + } else { + LetterAvatar(channelName, 34.dp, IrisColors.primary) + } Spacer(modifier = Modifier.width(10.dp)) Column { Text( @@ -1166,12 +1252,16 @@ private fun TitlePill( /** M7: compact 36dp header icon button (smaller than the 48dp default). */ @Composable -internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) { +internal fun HeaderIconButton( + onClick: () -> Unit, + content: @Composable () -> Unit, +) { Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onClick), + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { content() @@ -1180,13 +1270,23 @@ internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Un /** M7: circle with the first letter of [name] (header / rail / drawer). */ @Composable -private fun LetterAvatar(name: String, size: Dp, color: Color) { - val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" +private fun LetterAvatar( + name: String, + size: Dp, + color: Color, +) { + val letter = + name + .trim() + .firstOrNull() + ?.uppercaseChar() + ?.toString() ?: "?" Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(color), + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(color), contentAlignment = Alignment.Center, ) { Text( @@ -1210,7 +1310,10 @@ private fun parseHexColor(hex: String): Color { * tinted with the custom color (or the auto-generated name-hash color). */ @Composable -private fun ChannelAvatar(channel: ChannelInfo, size: Dp) { +private fun ChannelAvatar( + channel: ChannelInfo, + size: Dp, +) { if (channel.icon != null) { IconImage(channel.icon, size) } else { @@ -1221,16 +1324,20 @@ private fun ChannelAvatar(channel: ChannelInfo, size: Dp) { /** Decodes a base64 channel-icon image and shows it (circular crop). */ @Composable -private fun IconImage(base64: String, size: Dp) { +private fun IconImage( + base64: String, + size: Dp, +) { var bitmap by remember(base64) { mutableStateOf(null) } LaunchedEffect(base64) { bitmap = decodeBase64Image(base64) } Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(IrisColors.panel), + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(IrisColors.panel), contentAlignment = Alignment.Center, ) { bitmap?.let { @@ -1299,32 +1406,60 @@ private fun ChannelContextMenu( ) { val hasIcon = ch.icon != null || ch.color != null DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem(text = { Text("Rename") }, onClick = { onDismiss(); onRename() }) + DropdownMenuItem(text = { Text("Rename") }, onClick = { + onDismiss() + onRename() + }) DropdownMenuItem( text = { Text(if (ch.favorite) "Unfavorite" else "Favorite") }, - onClick = { onDismiss(); onToggleFavorite() }, + onClick = { + onDismiss() + onToggleFavorite() + }, ) DropdownMenuItem( text = { Text(if (hasIcon) "Change icon" else "Add icon") }, - onClick = { onDismiss(); onEditIcon() }, + onClick = { + onDismiss() + onEditIcon() + }, ) if (!ch.isDefault) { DropdownMenuItem( text = { Text(if (ch.automation) "Unmark automation" else "Mark as automation") }, - onClick = { onDismiss(); onToggleAutomation() }, + onClick = { + onDismiss() + onToggleAutomation() + }, ) - DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() }) + DropdownMenuItem(text = { Text("Delete") }, onClick = { + onDismiss() + onDelete() + }) } } } /** Preset avatar colors for the channel-icon color picker. */ -private val AVATAR_COLORS = listOf( - "#E57373", "#F06292", "#BA68C8", "#9575CD", "#7986CB", - "#64B5F6", "#4FC3F7", "#4DD0E1", "#4DB6AC", "#81C784", - "#AED581", "#FFD54F", "#FFB74D", "#FF8A65", "#A1887F", - "#90A4AE", -) +private val AVATAR_COLORS = + listOf( + "#E57373", + "#F06292", + "#BA68C8", + "#9575CD", + "#7986CB", + "#64B5F6", + "#4FC3F7", + "#4DD0E1", + "#4DB6AC", + "#81C784", + "#AED581", + "#FFD54F", + "#FFB74D", + "#FF8A65", + "#A1887F", + "#90A4AE", + ) /** A small grid of preset color swatches. */ @Composable @@ -1334,12 +1469,13 @@ private fun ColorPickerGrid(onPick: (String) -> Unit) { Row(modifier = Modifier.padding(vertical = 4.dp)) { AVATAR_COLORS.subList(rowStart, minOf(rowStart + 5, AVATAR_COLORS.size)).forEach { color -> Box( - modifier = Modifier - .size(36.dp) - .padding(3.dp) - .clip(CircleShape) - .background(parseHexColor(color)) - .clickable { onPick(color) }, + modifier = + Modifier + .size(36.dp) + .padding(3.dp) + .clip(CircleShape) + .background(parseHexColor(color)) + .clickable { onPick(color) }, ) } } @@ -1429,13 +1565,14 @@ private fun ChannelDrawer( val isCurrent = ch.chatId == currentChatId var menuOpen by remember { mutableStateOf(false) } Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(if (isCurrent) IrisColors.chipSelected else Color.Transparent) - .combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true }) - .rightClick { menuOpen = true } - .padding(horizontal = 10.dp, vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (isCurrent) IrisColors.chipSelected else Color.Transparent) + .combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true }) + .rightClick { menuOpen = true } + .padding(horizontal = 10.dp, vertical = 6.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { ChannelAvatar(ch, 28.dp) @@ -1495,32 +1632,37 @@ private fun ChannelRail( onOpenSettings: () -> Unit, ) { Column( - modifier = Modifier - .width(280.dp) - .fillMaxHeight() - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { e -> - if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (e.key) { - Key.DirectionUp -> { - if (channels.isNotEmpty()) { - onSelectedIndex((selectedIndex - 1 + channels.size) % channels.size) + modifier = + Modifier + .width(280.dp) + .fillMaxHeight() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { e -> + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp -> { + if (channels.isNotEmpty()) { + onSelectedIndex((selectedIndex - 1 + channels.size) % channels.size) + } + true + } + + Key.DirectionDown -> { + if (channels.isNotEmpty()) onSelectedIndex((selectedIndex + 1) % channels.size) + true + } + + Key.Enter -> { + channels.getOrNull(selectedIndex)?.let { onOpen(it) } + true + } + + else -> { + false } - true } - Key.DirectionDown -> { - if (channels.isNotEmpty()) onSelectedIndex((selectedIndex + 1) % channels.size) - true - } - Key.Enter -> { - channels.getOrNull(selectedIndex)?.let { onOpen(it) } - true - } - else -> false - } - } - .padding(12.dp), + }.padding(12.dp), ) { Text( "Channels", @@ -1536,28 +1678,29 @@ private fun ChannelRail( val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId var menuOpen by remember { mutableStateOf(false) } Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - when { - isCurrent -> IrisColors.chipSelected - isSelected -> IrisColors.rowSelected - else -> Color.Transparent - }, - ) - .combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true }) - .rightClick { menuOpen = true } - .padding(horizontal = 10.dp, vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + when { + isCurrent -> IrisColors.chipSelected + isSelected -> IrisColors.rowSelected + else -> Color.Transparent + }, + ).combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true }) + .rightClick { menuOpen = true } + .padding(horizontal = 10.dp, vertical = 6.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { // M7: accent bar marks the active channel. Box( - modifier = Modifier - .width(3.dp) - .height(22.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (isCurrent) IrisColors.primary else Color.Transparent), + modifier = + Modifier + .width(3.dp) + .height(22.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (isCurrent) IrisColors.primary else Color.Transparent), ) Spacer(modifier = Modifier.width(8.dp)) ChannelAvatar(ch, 28.dp) @@ -1604,15 +1747,17 @@ private fun InspectorPane( threads: List, items: List, ) { - val lastAssistant = items.lastOrNull { - it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming - } as? MessageItem + val lastAssistant = + items.lastOrNull { + it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming + } as? MessageItem Column( - modifier = Modifier - .width(300.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - .padding(12.dp), + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(12.dp), ) { Text( "Inspector", @@ -1637,20 +1782,24 @@ private fun InspectorPane( } @Composable -private fun InfoRow(label: String, value: String) { +private fun InfoRow( + label: String, + value: String, +) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Text(label, fontSize = 12.sp, color = IrisColors.textDim, modifier = Modifier.width(70.dp)) Text(value, fontSize = 12.sp, modifier = Modifier.weight(1f)) } } -private fun statusLabel(state: GatewayClient.State): String = when (state) { - GatewayClient.State.Disconnected -> "offline" - GatewayClient.State.Connecting -> "connecting…" - GatewayClient.State.Reconnecting -> "reconnecting…" - is GatewayClient.State.Connected -> "connected" - is GatewayClient.State.AuthFailed -> "auth failed" -} +private fun statusLabel(state: GatewayClient.State): String = + when (state) { + GatewayClient.State.Disconnected -> "offline" + GatewayClient.State.Connecting -> "connecting…" + GatewayClient.State.Reconnecting -> "reconnecting…" + is GatewayClient.State.Connected -> "connected" + is GatewayClient.State.AuthFailed -> "auth failed" + } /** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */ @Composable @@ -1678,11 +1827,12 @@ private fun CommandPalette( } else { filtered.forEach { (id, label) -> Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .clickable { onAction(id) } - .padding(vertical = 6.dp, horizontal = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable { onAction(id) } + .padding(vertical = 6.dp, horizontal = 4.dp), ) { Text(label, fontSize = 13.sp) } @@ -1707,10 +1857,11 @@ private fun TopicSwitcher( onDeleteThread: (ChannelInfo) -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { TopicChip("General", currentThreadId == null, onClick = onGeneral) @@ -1744,21 +1895,21 @@ internal fun TopicChip( var menuOpen by remember { mutableStateOf(false) } val hasMenu = onRename != null || onDelete != null Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) IrisColors.primary else IrisColors.chip) - .then( - if (hasMenu) { - // Tap (both platforms) + long-press (touch / held mouse) + - // right-click (desktop mouse, no-op on touch). - Modifier - .combinedClickable(onClick = onClick, onLongClick = { menuOpen = true }) - .rightClick { menuOpen = true } - } else { - Modifier.clickable(onClick = onClick) - } - ) - .padding(horizontal = 10.dp, vertical = 5.dp), + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) IrisColors.primary else IrisColors.chip) + .then( + if (hasMenu) { + // Tap (both platforms) + long-press (touch / held mouse) + + // right-click (desktop mouse, no-op on touch). + Modifier + .combinedClickable(onClick = onClick, onLongClick = { menuOpen = true }) + .rightClick { menuOpen = true } + } else { + Modifier.clickable(onClick = onClick) + }, + ).padding(horizontal = 10.dp, vertical = 5.dp), ) { Text(label, fontSize = 12.sp, color = if (selected) Color.White else IrisColors.textTertiary) if (hasMenu) { @@ -1811,10 +1962,11 @@ private fun SearchOverlay( Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) { Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Search", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) @@ -1864,16 +2016,21 @@ private fun SearchOverlay( } @Composable -private fun SearchHitRow(hit: SearchHit, channels: ChannelStore, onClick: () -> Unit) { +private fun SearchHitRow( + hit: SearchHit, + channels: ChannelStore, + onClick: () -> Unit, +) { val channel = channels.byId(hit.chatId) val thread = hit.threadId?.let { channels.byId(it) } Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(IrisColors.panel) - .clickable(onClick = onClick) - .padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.panel) + .clickable(onClick = onClick) + .padding(10.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -1920,25 +2077,34 @@ private fun NameDialog( } /** Human-readable connection status for the status-bubble tap toast. */ -private fun statusToastText(state: GatewayClient.State): String = when (state) { - is GatewayClient.State.Connected -> "Connected to Hermes" - GatewayClient.State.Connecting -> "Connecting to Hermes" - GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes" - GatewayClient.State.Disconnected -> "Unpaired from Hermes" - is GatewayClient.State.AuthFailed -> "Unpaired from Hermes" -} +private fun statusToastText(state: GatewayClient.State): String = + when (state) { + is GatewayClient.State.Connected -> "Connected to Hermes" + GatewayClient.State.Connecting -> "Connecting to Hermes" + GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes" + GatewayClient.State.Disconnected -> "Unpaired from Hermes" + is GatewayClient.State.AuthFailed -> "Unpaired from Hermes" + } /** Header status bubble: green = connected, yellow pulsing = (re)connecting, * red = not paired / offline. Tapping shows a toast with the status text. */ @Composable -private fun StatusBubble(state: GatewayClient.State, onClick: () -> Unit) { - val (color, pulsing) = when (state) { - is GatewayClient.State.Connected -> IrisColors.statusGreen to false - GatewayClient.State.Connecting, - GatewayClient.State.Reconnecting -> IrisColors.statusAmber to true - GatewayClient.State.Disconnected, - is GatewayClient.State.AuthFailed -> IrisColors.statusRed to false - } +private fun StatusBubble( + state: GatewayClient.State, + onClick: () -> Unit, +) { + val (color, pulsing) = + when (state) { + is GatewayClient.State.Connected -> IrisColors.statusGreen to false + + GatewayClient.State.Connecting, + GatewayClient.State.Reconnecting, + -> IrisColors.statusAmber to true + + GatewayClient.State.Disconnected, + is GatewayClient.State.AuthFailed, + -> IrisColors.statusRed to false + } val alpha = remember { Animatable(1f) } LaunchedEffect(pulsing) { if (pulsing) { @@ -1952,17 +2118,19 @@ private fun StatusBubble(state: GatewayClient.State, onClick: () -> Unit) { } // 10dp visual dot with a larger (24dp) transparent tap target. Box( - modifier = Modifier - .padding(7.dp) - .clip(CircleShape) - .clickable(onClick = onClick), + modifier = + Modifier + .padding(7.dp) + .clip(CircleShape) + .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(color.copy(alpha = alpha.value)), + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(color.copy(alpha = alpha.value)), ) } } @@ -1978,17 +2146,19 @@ private fun ToastOverlay(message: String?) { Box(modifier = Modifier.fillMaxSize()) { AnimatedVisibility( visible = message != null, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 24.dp), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), enter = fadeIn(tween(150)), exit = fadeOut(tween(300)), ) { Box( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black.copy(alpha = 0.82f)) - .padding(horizontal = 16.dp, vertical = 10.dp), + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(Color.Black.copy(alpha = 0.82f)) + .padding(horizontal = 16.dp, vertical = 10.dp), ) { Text(shown, color = Color.White, fontSize = 13.sp) } @@ -2003,19 +2173,21 @@ private fun NotificationBanner( onOpen: () -> Unit, onDismiss: () -> Unit, ) { - val accent = when (banner.kind) { - "approval" -> IrisColors.statusAmber - "clarify" -> IrisColors.primary - "cron" -> IrisColors.statusGreen - else -> IrisColors.textDim - } + val accent = + when (banner.kind) { + "approval" -> IrisColors.statusAmber + "clarify" -> IrisColors.primary + "cron" -> IrisColors.statusGreen + else -> IrisColors.textDim + } Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(accent.copy(alpha = 0.12f)) - .clickable(onClick = onOpen) - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(accent.copy(alpha = 0.12f)) + .clickable(onClick = onOpen) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -2043,16 +2215,18 @@ private fun MessageBubble( val isUser = msg.role == ROLE_USER val isCommentary = msg.isCommentary val userTheme = LocalUserTheme.current - val bubbleColor = when { - isUser -> userTheme.userBubble - isCommentary -> IrisColors.bubbleCommentary - else -> userTheme.agentBubble - } - val textColor = when { - isUser -> userTheme.userBubble.contrastText() - isCommentary -> IrisColors.onSurface.copy(alpha = 0.55f) - else -> userTheme.agentBubble.contrastText() - } + val bubbleColor = + when { + isUser -> userTheme.userBubble + isCommentary -> IrisColors.bubbleCommentary + else -> userTheme.agentBubble + } + val textColor = + when { + isUser -> userTheme.userBubble.contrastText() + isCommentary -> IrisColors.onSurface.copy(alpha = 0.55f) + else -> userTheme.agentBubble.contrastText() + } val time = formatTime(msg.ts) Row( modifier = Modifier.fillMaxWidth(), @@ -2065,26 +2239,28 @@ private fun MessageBubble( Spacer(modifier = Modifier.width(6.dp)) } Column( - modifier = Modifier - .widthIn(max = maxWidth) - .clip(RoundedCornerShape(14.dp)) - .background(if (selected) IrisColors.chipSelected else bubbleColor) - .then( - // Long-press / right-click enters (or toggles within) message - // selection; a plain tap toggles while selecting, or retries a - // failed user send otherwise. Attached always so the long-press - // affordance exists even outside selection mode. - Modifier - .combinedClickable( - onClick = { - if (selectionMode) onToggleSelect() - else if (isUser && msg.status == MsgStatus.Failed) onRetry() - }, - onLongClick = { onLongPress() }, - ) - .rightClick { onLongPress() } - ) - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .widthIn(max = maxWidth) + .clip(RoundedCornerShape(14.dp)) + .background(if (selected) IrisColors.chipSelected else bubbleColor) + .then( + // Long-press / right-click enters (or toggles within) message + // selection; a plain tap toggles while selecting, or retries a + // failed user send otherwise. Attached always so the long-press + // affordance exists even outside selection mode. + Modifier + .combinedClickable( + onClick = { + if (selectionMode) { + onToggleSelect() + } else if (isUser && msg.status == MsgStatus.Failed) { + onRetry() + } + }, + onLongClick = { onLongPress() }, + ).rightClick { onLongPress() }, + ).padding(horizontal = 12.dp, vertical = 8.dp), ) { // Reasoning block above the answer (assistant, non-commentary). if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) { @@ -2097,8 +2273,9 @@ private fun MessageBubble( // in replies. if (msg.text.isNotBlank() || msg.streaming) { MarkdownText( - text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + - if (msg.streaming) " ▉" else "", + text = + msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + + if (msg.streaming) " ▉" else "", color = textColor, fontSize = 15.sp, isStreaming = msg.streaming, @@ -2115,13 +2292,21 @@ private fun MessageBubble( Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f)) } when (msg.status) { - MsgStatus.Pending -> + MsgStatus.Pending -> { Text(" sending…", fontSize = 10.sp, color = textColor.copy(alpha = 0.6f)) - MsgStatus.Sent -> + } + + MsgStatus.Sent -> { Text(" ✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f)) - MsgStatus.Read -> + } + + MsgStatus.Read -> { Text(" ✓✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f)) - MsgStatus.Failed -> Unit + } + + MsgStatus.Failed -> { + Unit + } } } if (msg.status == MsgStatus.Failed) { @@ -2140,8 +2325,9 @@ private fun MessageBubble( // bubble; single newlines become hard breaks (models write // status lines and wrapped text expecting a break per line, // same as user input); the ▉ cursor is kept while streaming. - val displayText = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + - if (msg.streaming) " ▉" else "" + val displayText = + msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + + if (msg.streaming) " ▉" else "" MarkdownText( text = displayText, color = textColor, @@ -2168,7 +2354,7 @@ private fun MessageBubble( buildString { msg.model?.let { append(it) } if (msg.model != null && msg.tokens != null) append(" · ") - msg.tokens?.let { append("${it} tok") } + msg.tokens?.let { append("$it tok") } }, color = textColor.copy(alpha = 0.4f), fontSize = 10.sp, @@ -2196,15 +2382,16 @@ private fun MessageBubble( @Composable private fun SelectionCheck(selected: Boolean) { Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(if (selected) IrisColors.primary else Color.Transparent) - .border( - width = if (selected) 0.dp else 1.5.dp, - color = IrisColors.textDim, - shape = CircleShape, - ), + modifier = + Modifier + .size(22.dp) + .clip(CircleShape) + .background(if (selected) IrisColors.primary else Color.Transparent) + .border( + width = if (selected) 0.dp else 1.5.dp, + color = IrisColors.textDim, + shape = CircleShape, + ), contentAlignment = Alignment.Center, ) { if (selected) Text("✓", color = Color.White, fontSize = 12.sp) @@ -2213,14 +2400,19 @@ private fun SelectionCheck(selected: Boolean) { /** Toolbar shown in place of the composer while messages are selected. */ @Composable -private fun SelectionToolbar(count: Int, onCancel: () -> Unit, onDelete: () -> Unit) { +private fun SelectionToolbar( + count: Int, + onCancel: () -> Unit, + onDelete: () -> Unit, +) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .clip(RoundedCornerShape(28.dp)) - .background(IrisColors.surface) - .padding(horizontal = 8.dp, vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .clip(RoundedCornerShape(28.dp)) + .background(IrisColors.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { HeaderIconButton(onClick = onCancel) { @@ -2233,11 +2425,12 @@ private fun SelectionToolbar(count: Int, onCancel: () -> Unit, onDelete: () -> U modifier = Modifier.weight(1f), ) Box( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(if (count > 0) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f)) - .clickable(enabled = count > 0) { onDelete() } - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (count > 0) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f)) + .clickable(enabled = count > 0) { onDelete() } + .padding(horizontal = 16.dp, vertical = 8.dp), contentAlignment = Alignment.Center, ) { Text("🗑 Delete", color = Color.White, fontSize = 14.sp) @@ -2276,14 +2469,18 @@ private fun MediaImage(media: MediaItem) { if (loading || bitmap == null) { Box( - modifier = Modifier - .size(120.dp) - .clip(RoundedCornerShape(10.dp)) - .background(IrisColors.panel), + modifier = + Modifier + .size(120.dp) + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.panel), contentAlignment = Alignment.Center, ) { - if (media.localPath == null) Text("downloading…", fontSize = 11.sp) - else CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + if (media.localPath == null) { + Text("downloading…", fontSize = 11.sp) + } else { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } } return } @@ -2292,33 +2489,40 @@ private fun MediaImage(media: MediaItem) { // Preserve the image's aspect ratio, capping the dominant dimension at // 260.dp so a chat image never dominates the screen (the other dimension // follows the ratio). - val (imgW, imgH) = if (aspect >= 1f) 260.dp to (260 / aspect).dp - else (260 * aspect).dp to 260.dp + val (imgW, imgH) = + if (aspect >= 1f) { + 260.dp to (260 / aspect).dp + } else { + (260 * aspect).dp to 260.dp + } Image( bitmap = bmp, contentDescription = media.filename, contentScale = ContentScale.FillBounds, - modifier = Modifier - .width(imgW) - .height(imgH) - .clip(RoundedCornerShape(10.dp)) - .clickable { showFull = true }, + modifier = + Modifier + .width(imgW) + .height(imgH) + .clip(RoundedCornerShape(10.dp)) + .clickable { showFull = true }, ) if (showFull) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .clickable { showFull = false }, + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { showFull = false }, contentAlignment = Alignment.Center, ) { Image( bitmap = bitmap!!, contentDescription = media.filename, contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.9f), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f), ) } } @@ -2327,13 +2531,13 @@ private fun MediaImage(media: MediaItem) { @Composable private fun MediaDocChip(media: MediaItem) { Row( - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background(IrisColors.panel) - .clickable { - media.localPath?.let { openDocument(it, media.mime) } - } - .padding(horizontal = 10.dp, vertical = 8.dp), + modifier = + Modifier + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.panel) + .clickable { + media.localPath?.let { openDocument(it, media.mime) } + }.padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text("📄", fontSize = 16.sp) @@ -2345,25 +2549,31 @@ private fun MediaDocChip(media: MediaItem) { } } -private fun fmtSize(bytes: Long): String = when { - bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) - bytes >= 1024 -> "%.0f KB".format(bytes / 1024.0) - else -> "$bytes B" -} +private fun fmtSize(bytes: Long): String = + when { + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1024 -> "%.0f KB".format(bytes / 1024.0) + else -> "$bytes B" + } /** M4: composer attachment chip (uploading / ready / error). */ @Composable -private fun AttachmentChip(att: IrisController.PendingAttachment, onRemove: () -> Unit) { - val (icon, label) = when { - att.error != null -> "⚠️" to "${att.filename} — ${att.error}" - att.uploading -> "⏳" to "${att.filename} (${fmtSize(att.size)})" - else -> "📎" to "${att.filename} (${fmtSize(att.size)})" - } +private fun AttachmentChip( + att: IrisController.PendingAttachment, + onRemove: () -> Unit, +) { + val (icon, label) = + when { + att.error != null -> "⚠️" to "${att.filename} — ${att.error}" + att.uploading -> "⏳" to "${att.filename} (${fmtSize(att.size)})" + else -> "📎" to "${att.filename} (${fmtSize(att.size)})" + } Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(IrisColors.chip) - .padding(horizontal = 8.dp, vertical = 5.dp), + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(IrisColors.chip) + .padding(horizontal = 8.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(icon, fontSize = 12.sp) @@ -2381,7 +2591,11 @@ private fun AttachmentChip(att: IrisController.PendingAttachment, onRemove: () - * expanded. Tap to toggle. */ @Composable -private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor: Color) { +private fun ReasoningBlock( + reasoning: String, + autoCollapse: Boolean, + bubbleColor: Color, +) { val clipboard = LocalClipboardManager.current var expanded by remember(autoCollapse) { mutableStateOf(!autoCollapse || reasoning.length <= 240) @@ -2391,18 +2605,20 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor // 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 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(Color.Black.copy(alpha = 0.6f)) - .padding(8.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(8.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -2410,9 +2626,10 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = textColor, - modifier = Modifier - .weight(1f) - .clickable { expanded = !expanded }, + modifier = + Modifier + .weight(1f) + .clickable { expanded = !expanded }, ) TextButton(onClick = { clipboard.setText(AnnotatedString(reasoning)) @@ -2443,26 +2660,32 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor * ✓/✗ + duration on completion. Verbosity controlled by [detail]. */ @Composable -private fun ToolCard(tool: ToolItem, detail: ToolDetail) { +private fun ToolCard( + tool: ToolItem, + detail: ToolDetail, +) { // Everything starts expanded (show the full call + output); Truncated // starts collapsed and reveals them on tap. var expanded by remember(detail) { mutableStateOf(detail == ToolDetail.EVERYTHING) } - val status = when { - !tool.done -> null - tool.ok -> "✓" - else -> "✗" - } - val statusColor = when { - tool.ok -> IrisColors.statusGreen - else -> IrisColors.statusRed - } + val status = + when { + !tool.done -> null + tool.ok -> "✓" + else -> "✗" + } + val statusColor = + when { + tool.ok -> IrisColors.statusGreen + else -> IrisColors.statusRed + } val preview = tool.preview ?: tool.note Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(IrisColors.panel) - .padding(horizontal = 10.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.panel) + .padding(horizontal = 10.dp, vertical = 8.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { if (!tool.done) { @@ -2480,9 +2703,10 @@ private fun ToolCard(tool: ToolItem, detail: ToolDetail) { fontSize = 13.sp, fontWeight = FontWeight.Medium, color = IrisColors.textBright, - modifier = Modifier - .weight(1f) - .clickable { expanded = !expanded }, + modifier = + Modifier + .weight(1f) + .clickable { expanded = !expanded }, ) if (tool.duration != null) { Text( @@ -2526,11 +2750,12 @@ private fun ToolCard(tool: ToolItem, detail: ToolDetail) { } internal val ToolDetail.label: String - get() = when (this) { - ToolDetail.EVERYTHING -> "everything" - ToolDetail.TRUNCATED -> "truncated" - ToolDetail.NOTHING -> "nothing" - } + get() = + when (this) { + ToolDetail.EVERYTHING -> "everything" + ToolDetail.TRUNCATED -> "truncated" + ToolDetail.NOTHING -> "nothing" + } // ── Slash-command drawer ────────────────────────────────────────────────── @@ -2539,11 +2764,14 @@ private const val SLASH_DRAWER_MAX = 8 /** Best fuzzy rank of [query] against a command's name or any of its * aliases; -1 when nothing matches. */ -private fun bestSlashScore(query: String, cmd: SlashCommand): Int { +private fun bestSlashScore( + query: String, + cmd: SlashCommand, +): Int { var best = fuzzyScore(query, cmd.name.removePrefix("/")) for (alias in cmd.aliases) { val s = fuzzyScore(query, alias.removePrefix("/")) if (s >= 0 && (best < 0 || s < best)) best = s } return best -} \ No newline at end of file +} 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 8547169..1eacc9f 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -44,9 +45,12 @@ 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.decodeImageBytes import iris.platform.loadScaledImage +import iris.platform.readBackdropBytes import iris.state.IrisController import iris.state.ToolDetail +import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.IrisColors import iris.ui.theme.LocalUserTheme @@ -56,11 +60,15 @@ 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) { +fun SettingsScreen( + controller: IrisController, + onBack: () -> Unit, +) { val threadsEnabled by controller.threadsEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() @@ -71,10 +79,11 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(12.dp), + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( @@ -220,14 +229,36 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) { ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background } } } else { + Spacer(modifier = Modifier.height(8.dp)) + Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim) + Spacer(modifier = Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Backdrop.ALL.forEach { backdrop -> + BackdropThumb( + backdrop = backdrop, + selected = theme.backgroundImagePath == backdrop.path, + onClick = { + controller.setBackgroundMode(BackgroundMode.Image) + controller.setBackgroundImagePath(backdrop.path) + }, + ) + } + } 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!!), + if (theme.backgroundImagePath.isNullOrBlank()) { + "No image selected" + } else { + Backdrop.fromPath(theme.backgroundImagePath)?.name + ?: fileNameOf(theme.backgroundImagePath!!) + }, fontSize = 12.sp, color = IrisColors.textDim, ) @@ -328,58 +359,73 @@ 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 + 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 + } } } } /** Material-style back arrow (vector, matches the app's icon weight). */ @Composable -private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) { +private fun BackArrow( + size: Dp = 22.dp, + color: Color = IrisColors.onSurface, +) { Canvas(modifier = Modifier.size(size)) { val s = this.size.width / 24f - val path = Path().apply { - moveTo(20f * s, 11f * s) - lineTo(7.83f * s, 11f * s) - lineTo(13.42f * s, 5.41f * s) - lineTo(12f * s, 4f * s) - lineTo(4f * s, 12f * s) - lineTo(12f * s, 20f * s) - lineTo(13.41f * s, 18.59f * s) - lineTo(7.83f * s, 13f * s) - lineTo(20f * s, 13f * s) - close() - } + val path = + Path().apply { + moveTo(20f * s, 11f * s) + lineTo(7.83f * s, 11f * s) + lineTo(13.42f * s, 5.41f * s) + lineTo(12f * s, 4f * s) + lineTo(4f * s, 12f * s) + lineTo(12f * s, 20f * s) + lineTo(13.41f * s, 18.59f * s) + lineTo(7.83f * s, 13f * s) + lineTo(20f * s, 13f * s) + close() + } drawPath(path, color) } } @@ -388,11 +434,12 @@ private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) { @Composable private fun SettingsCard(content: @Composable () -> Unit) { Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(IrisColors.panel) - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(IrisColors.panel) + .padding(12.dp), ) { content() } @@ -400,14 +447,18 @@ private fun SettingsCard(content: @Composable () -> Unit) { /** Clickable rounded chip showing a color; opens the picker on tap. */ @Composable -private fun ColorSwatch(color: Color, onClick: () -> Unit) { +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), + modifier = + Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color) + .border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), ) } @@ -416,14 +467,25 @@ private fun ColorSwatch(color: Color, onClick: () -> Unit) { private fun BackgroundPreview(path: String?) { var bmp by remember(path) { mutableStateOf(null) } LaunchedEffect(path) { - bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128) + bmp = + if (path.isNullOrBlank()) { + null + } else { + val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) } + if (bytes != null) { + decodeImageBytes(bytes, 128) + } 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)), + 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 @@ -438,6 +500,43 @@ private fun BackgroundPreview(path: String?) { } } +/** Thumbnail of a bundled backdrop; highlighted when selected. */ +@Composable +private fun BackdropThumb( + backdrop: Backdrop, + selected: Boolean, + onClick: () -> Unit, +) { + var bmp by remember(backdrop.id) { mutableStateOf(null) } + LaunchedEffect(backdrop.id) { + val bytes = readBackdropBytes(backdrop.id) + bmp = if (bytes != null) decodeImageBytes(bytes, 128) else null + } + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(IrisColors.panel) + .border( + if (selected) 2.dp else 1.dp, + if (selected) IrisColors.primary else IrisColors.divider, + RoundedCornerShape(8.dp), + ).clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + val b = bmp + if (b != null) { + Image( + bitmap = b, + contentDescription = backdrop.name, + 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; @@ -462,11 +561,12 @@ private fun ColorPickerDialog( text = { Column(modifier = Modifier.fillMaxWidth()) { Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(color), + 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) @@ -505,11 +605,12 @@ private fun argbToHsv(argb: Int): FloatArray { 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 = + when (max) { + r -> ((g - b) / delta) % 6f + g -> (b - r) / delta + 2f + else -> (r - g) / delta + 4f + } hue *= 60f if (hue < 0f) hue += 360f } @@ -518,5 +619,4 @@ private fun argbToHsv(argb: Int): FloatArray { } /** 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 +private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/theme/Backdrop.kt b/app/shared/src/commonMain/kotlin/iris/ui/theme/Backdrop.kt new file mode 100644 index 0000000..0250a18 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/ui/theme/Backdrop.kt @@ -0,0 +1,40 @@ +package iris.ui.theme + +/** + * A bundled backdrop image shipped in the app (Android `assets/backdrops/`, + * desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes]. + * + * Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the + * sentinel path `backdrop://` (see [path]); user-picked images use a real + * file path. [DEFAULT] is the background for fresh installs. + */ +data class Backdrop( + val id: String, + val name: String, +) { + /** Sentinel path stored in the theme for this bundled backdrop. */ + val path: String get() = "backdrop://$id" + + companion object { + const val PATH_PREFIX = "backdrop://" + + val ALL: List = + listOf( + Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"), + Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"), + Backdrop("pexels-bosichong-27940302", "Bosi Chong"), + Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"), + Backdrop("pexels-soubhagya23-8060218", "Soubhagya"), + Backdrop("pexels-steve-29390703", "Steve"), + ) + + /** Default background for new chats / fresh installs. */ + val DEFAULT: Backdrop = ALL.first { it.id == "pexels-yunszyveli-12368637" } + + /** True when [path] references a bundled backdrop. */ + fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX) + + /** The bundled backdrop for a sentinel path, or null. */ + fun fromPath(path: String?): Backdrop? = if (isBackdropPath(path)) ALL.firstOrNull { it.path == path } else null + } +} 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 9e2c096..f89e0a2 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/theme/Theme.kt @@ -11,7 +11,9 @@ 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.decodeImageBytes import iris.platform.loadScaledImage +import iris.platform.readBackdropBytes /** * Centralized palette (M7). Dark is the only theme; every screen reads colors @@ -54,16 +56,17 @@ object IrisColors { val errorText = Color(0xFFFF8A80) } -private val IrisDark = darkColorScheme( - background = IrisColors.background, - surface = IrisColors.surface, - onBackground = IrisColors.onBackground, - onSurface = IrisColors.onSurface, - onSurfaceVariant = IrisColors.textDim, - primary = IrisColors.primary, - onPrimary = Color.White, - error = IrisColors.statusRed, -) +private val IrisDark = + darkColorScheme( + background = IrisColors.background, + surface = IrisColors.surface, + onBackground = IrisColors.onBackground, + onSurface = IrisColors.onSurface, + onSurfaceVariant = IrisColors.textDim, + primary = IrisColors.primary, + onPrimary = Color.White, + error = IrisColors.statusRed, + ) @Composable fun IrisTheme(content: @Composable () -> Unit) { @@ -85,9 +88,11 @@ fun avatarColor(name: String): Color { data class UserTheme( val userBubbleColor: Int = DEFAULT_USER_BUBBLE, val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE, - val backgroundMode: BackgroundMode = BackgroundMode.Color, + // Fresh installs start with the default bundled backdrop (Settings → + // Appearance to change it). + val backgroundMode: BackgroundMode = BackgroundMode.Image, val backgroundColor: Int = DEFAULT_BACKGROUND, - val backgroundImagePath: String? = null, + val backgroundImagePath: String? = Backdrop.DEFAULT.path, ) { val userBubble: Color get() = Color(userBubbleColor) val agentBubble: Color get() = Color(agentBubbleColor) @@ -114,8 +119,10 @@ fun Color.contrastText(): Color { /** * 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. + * 2048 px on the longest edge. Bundled backdrops (`backdrop://`) are read + * from the app resources; anything else is a user-picked file path. Null in + * color mode or on decode failure — the caller then falls back to the solid + * background color. */ @Composable fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? { @@ -123,7 +130,18 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? { 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 + image = + if (!wantImage) { + null + } else { + val backdrop = Backdrop.fromPath(path) + val bytes = backdrop?.let { readBackdropBytes(it.id) } + if (bytes != null) { + decodeImageBytes(bytes, 2048) + } else { + loadScaledImage(path!!, 2048) + } + } } 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 5509e72..b99b023 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt @@ -1,5 +1,7 @@ package iris.platform +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.material3.Button import androidx.compose.material3.IconButton import androidx.compose.material3.Slider @@ -31,17 +31,6 @@ 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 -import java.net.UnixDomainSocketAddress -import java.nio.ByteBuffer -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 @@ -53,6 +42,18 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.StandardProtocolFamily +import java.net.UnixDomainSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.SocketChannel +import java.util.concurrent.TimeUnit +import javax.imageio.ImageIO +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter import kotlin.random.Random /** @@ -63,9 +64,10 @@ import kotlin.random.Random actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { var busy by remember { mutableStateOf(false) } Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { Text("Attach a file:", fontSize = 13.sp) Spacer(modifier = Modifier.height(8.dp)) @@ -98,9 +100,10 @@ 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") - } + val chooser = + JFileChooser().apply { + fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif") + } if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) { onPicked(null) return@Button @@ -151,62 +154,110 @@ actual fun mediaCacheBaseDir(): String { return dir.absolutePath } -actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) { +actual suspend fun loadMediaImage(path: String): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + val img = ImageIO.read(File(path)) ?: return@withContext null + img.toComposeImageBitmap() + } catch (_: Exception) { + null + } + } + +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 + } + } + +actual suspend fun decodeImageBytes( + bytes: ByteArray, + maxSize: Int, +): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + val img = ImageIO.read(ByteArrayInputStream(bytes)) ?: 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 + } + } + +actual fun readBackdropBytes(id: String): ByteArray? = try { - val img = ImageIO.read(File(path)) ?: return@withContext null - img.toComposeImageBitmap() - } catch (_: Exception) { + // Top-level function: no `javaClass`, so anchor the classpath lookup to a + // class in this module (PickedFile, same package). + PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() } + } catch (e: Exception) { null } -} -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 { +actual suspend fun encodeImageAsBase64( + path: String, + maxSize: Int, +): String? = + withContext(Dispatchers.IO) { + try { + val img = ImageIO.read(File(path)) ?: return@withContext null + val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f) 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() + val baos = ByteArrayOutputStream() + ImageIO.write(scaled, "png", baos) + java.util.Base64 + .getEncoder() + .encodeToString(baos.toByteArray()) + } catch (_: Exception) { + null } - } catch (_: Exception) { - null } -} -actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) { - try { - val img = ImageIO.read(File(path)) ?: return@withContext null - val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f) - 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() - val baos = ByteArrayOutputStream() - ImageIO.write(scaled, "png", baos) - java.util.Base64.getEncoder().encodeToString(baos.toByteArray()) - } catch (_: Exception) { - null +actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = + withContext(Dispatchers.IO) { + try { + val bytes = + java.util.Base64 + .getDecoder() + .decode(base64) + val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null + img.toComposeImageBitmap() + } catch (_: Exception) { + null + } } -} - -actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) { - try { - val bytes = java.util.Base64.getDecoder().decode(base64) - val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null - img.toComposeImageBitmap() - } catch (_: Exception) { - null - } -} /** * M6: inline media playback backed by mpv (docs/11 §11.2). @@ -219,7 +270,10 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext * - no mpv on PATH: an info chip (the WebView fallback is a stretch goal). */ @Composable -actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { +actual fun MediaPlayerView( + media: MediaItem, + modifier: Modifier, +) { val path = media.localPath if (path == null || !File(path).exists()) { Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -246,7 +300,12 @@ private object Mpv { val available: Boolean by lazy { try { val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start() - val found = p.inputStream.bufferedReader().readText().trim().isNotBlank() + val found = + p.inputStream + .bufferedReader() + .readText() + .trim() + .isNotBlank() p.waitFor(2, TimeUnit.SECONDS) found } catch (_: Exception) { @@ -256,7 +315,10 @@ private object Mpv { } @Composable -private fun AudioPlayer(media: MediaItem, path: String) { +private fun AudioPlayer( + media: MediaItem, + path: String, +) { val session = remember(path) { MpvAudioSession(path) } var paused by remember { mutableStateOf(true) } var position by remember { mutableStateOf(0.0) } @@ -278,11 +340,12 @@ private fun AudioPlayer(media: MediaItem, path: String) { } Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFF20242E)) - .padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF20242E)) + .padding(10.dp), ) { Text(media.filename, fontSize = 12.sp, maxLines = 1) Spacer(modifier = Modifier.height(6.dp)) @@ -309,14 +372,18 @@ private fun AudioPlayer(media: MediaItem, path: String) { } @Composable -private fun VideoPlayer(media: MediaItem, path: String) { +private fun VideoPlayer( + media: MediaItem, + path: String, +) { var process by remember { mutableStateOf(null) } Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFF20242E)) - .padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF20242E)) + .padding(10.dp), ) { Text(media.filename, fontSize = 12.sp, maxLines = 1) Spacer(modifier = Modifier.height(6.dp)) @@ -352,7 +419,9 @@ private fun fmtTime(sec: Double): String { * An mpv process + its JSON IPC socket (unix domain). Commands carry a * [requestId] so responses can be matched and async events skipped. */ -private class MpvAudioSession(private val path: String) { +private class MpvAudioSession( + private val path: String, +) { private var process: Process? = null private var channel: SocketChannel? = null private val socketPath = @@ -362,10 +431,15 @@ private class MpvAudioSession(private val path: String) { fun start() { try { - process = ProcessBuilder( - "mpv", "--no-video", "--really-quiet", "--no-terminal", - "--input-ipc-server=$socketPath", path, - ).redirectErrorStream(true).start() + process = + ProcessBuilder( + "mpv", + "--no-video", + "--really-quiet", + "--no-terminal", + "--input-ipc-server=$socketPath", + path, + ).redirectErrorStream(true).start() connectIpc() } catch (_: Exception) { } @@ -406,9 +480,24 @@ private class MpvAudioSession(private val path: String) { } catch (_: Exception) { } } - answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it } - answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it } - answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it } + answers["p1"] + ?.get("data") + ?.jsonPrimitive + ?.content + ?.toDoubleOrNull() + ?.let { pos = it } + answers["p2"] + ?.get("data") + ?.jsonPrimitive + ?.content + ?.toDoubleOrNull() + ?.let { dur = it } + answers["p3"] + ?.get("data") + ?.jsonPrimitive + ?.content + ?.toBooleanStrictOrNull() + ?.let { pause = it } } catch (_: Exception) { } return Triple(pos, dur, pause) @@ -440,12 +529,16 @@ private class MpvAudioSession(private val path: String) { } } - private fun send(cmd: List, requestId: String?) { + private fun send( + cmd: List, + requestId: String?, + ) { val ch = channel ?: return - val obj = buildJsonObject { - put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } }) - requestId?.let { put("request_id", JsonPrimitive(it)) } - } + val obj = + buildJsonObject { + put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } }) + requestId?.let { put("request_id", JsonPrimitive(it)) } + } val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8) ch.write(ByteBuffer.wrap(bytes)) } @@ -471,15 +564,25 @@ private class MpvAudioSession(private val path: String) { } } -actual fun openDocument(path: String, mime: String) { +actual fun openDocument( + path: String, + mime: String, +) { try { val os = System.getProperty("os.name").lowercase() when { - os.contains("mac") -> ProcessBuilder("open", path).start() - os.contains("win") -> + os.contains("mac") -> { + ProcessBuilder("open", path).start() + } + + os.contains("win") -> { ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start() - else -> ProcessBuilder("xdg-open", path).start() + } + + else -> { + ProcessBuilder("xdg-open", path).start() + } } } catch (_: Exception) { } -} \ No newline at end of file +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index baba4ad..7ef3b67 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -2,14 +2,16 @@ package iris.platform import iris.data.SecureStore import iris.protocol.IrisJson +import iris.ui.theme.Backdrop +import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme +import kotlinx.serialization.Serializable import java.io.File import java.security.SecureRandom import java.util.UUID import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -import kotlinx.serialization.Serializable /** * M6: desktop pairing storage (docs/11 §11.2). @@ -41,9 +43,9 @@ class DesktopSecureStore : SecureStore { val reasoningAutoCollapse: Boolean = true, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, - val backgroundMode: String = "color", + val backgroundMode: String = BackgroundMode.Image.name.lowercase(), val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND, - val backgroundImagePath: String = "", + val backgroundImagePath: String = Backdrop.DEFAULT.path, val fontSizeScale: Float = 1.0f, ) @@ -53,11 +55,12 @@ class DesktopSecureStore : SecureStore { private fun migrateLegacy() { if (!legacyFile.exists()) return - val legacy = try { - IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText()) - } catch (_: Exception) { - null - } + val legacy = + try { + IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText()) + } catch (_: Exception) { + null + } if (legacy != null) { val s = load() save( @@ -108,7 +111,13 @@ class DesktopSecureStore : SecureStore { get() { val d = load() if (d.deviceId.isNotBlank()) return d.deviceId - val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16) + val id = + "dev_" + + UUID + .randomUUID() + .toString() + .replace("-", "") + .take(16) save(d.copy(deviceId = id)) return id } @@ -214,7 +223,10 @@ class DesktopSecureStore : SecureStore { save(d.copy(fontSizeScale = value)) } - override fun savePairing(url: String, token: String) { + override fun savePairing( + url: String, + token: String, + ) { val d = load() save(d.copy(serverUrl = url.trim())) if (token.isBlank()) secret.clear() else secret.write(token.trim()) @@ -243,7 +255,9 @@ private data class PairingData( * Token storage: OS keyring when available, else an AES-GCM encrypted file. * All backend failures degrade to the encrypted file (never plaintext). */ -private class SecretBackend(private val baseDir: File) { +private class SecretBackend( + private val baseDir: File, +) { private val encFile = File(baseDir, "pairing.enc") private val keyFile = File(baseDir, ".key") private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available } @@ -318,32 +332,49 @@ private class KeyringBackend { /** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */ val available: Boolean by lazy { - if (isMac) true - else if (isLinux) which("secret-tool") != null - else false + if (isMac) { + true + } else if (isLinux) { + which("secret-tool") != null + } else { + false + } } - fun read(): String? = try { - if (isMac) { - out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w")) - } else { - out(listOf("secret-tool", "lookup", "app", "iris")) + fun read(): String? = + try { + if (isMac) { + out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w")) + } else { + out(listOf("secret-tool", "lookup", "app", "iris")) + } + } catch (_: Exception) { + null } - } catch (_: Exception) { - null - } fun write(value: String) { try { if (isMac) { ProcessBuilder( - "security", "add-generic-password", "-U", - "-a", "iris", "-s", "iris-gateway-token", "-w", value, + "security", + "add-generic-password", + "-U", + "-a", + "iris", + "-s", + "iris-gateway-token", + "-w", + value, ).inheritIO().start().waitFor() } else { ProcessBuilder( - "secret-tool", "store", "--label=Iris gateway token", - "app", "iris", "token", value, + "secret-tool", + "store", + "--label=Iris gateway token", + "app", + "iris", + "token", + value, ).inheritIO().start().waitFor() } } catch (_: Exception) { @@ -354,12 +385,19 @@ private class KeyringBackend { try { if (isMac) { ProcessBuilder( - "security", "delete-generic-password", - "-a", "iris", "-s", "iris-gateway-token", + "security", + "delete-generic-password", + "-a", + "iris", + "-s", + "iris-gateway-token", ).inheritIO().start().waitFor() } else { ProcessBuilder( - "secret-tool", "clear", "app", "iris", + "secret-tool", + "clear", + "app", + "iris", ).inheritIO().start().waitFor() } } catch (_: Exception) { @@ -368,17 +406,26 @@ private class KeyringBackend { private fun out(cmd: List): String? { val p = ProcessBuilder(cmd).redirectErrorStream(true).start() - val text = p.inputStream.bufferedReader().readText().trim() + val text = + p.inputStream + .bufferedReader() + .readText() + .trim() p.waitFor() return text.ifBlank { null } } - private fun which(bin: String): String? = try { - val p = ProcessBuilder("which", bin).redirectErrorStream(true).start() - val text = p.inputStream.bufferedReader().readText().trim() - p.waitFor() - text.ifBlank { null } - } catch (_: Exception) { - null - } -} \ No newline at end of file + private fun which(bin: String): String? = + try { + val p = ProcessBuilder("which", bin).redirectErrorStream(true).start() + val text = + p.inputStream + .bufferedReader() + .readText() + .trim() + p.waitFor() + text.ifBlank { null } + } catch (_: Exception) { + null + } +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/Platform.kt b/app/shared/src/desktopMain/kotlin/iris/platform/Platform.kt index d648810..564d877 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/Platform.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/Platform.kt @@ -1,3 +1,3 @@ package iris.platform -actual val isDesktop: Boolean = true \ No newline at end of file +actual val isDesktop: Boolean = true diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-bogdankrupin-12049700.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-bogdankrupin-12049700.jpg new file mode 100644 index 0000000..dc460cb Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-bogdankrupin-12049700.jpg differ diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-bosichong-27940302.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-bosichong-27940302.jpg new file mode 100644 index 0000000..87930cc Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-bosichong-27940302.jpg differ diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-farhan-najeer-644774196-32490483.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-farhan-najeer-644774196-32490483.jpg new file mode 100644 index 0000000..9afb6f9 Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-farhan-najeer-644774196-32490483.jpg differ diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-soubhagya23-8060218.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-soubhagya23-8060218.jpg new file mode 100644 index 0000000..1651100 Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-soubhagya23-8060218.jpg differ diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-steve-29390703.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-steve-29390703.jpg new file mode 100644 index 0000000..99f1c88 Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-steve-29390703.jpg differ diff --git a/app/shared/src/desktopMain/resources/backdrops/pexels-yunszyveli-12368637.jpg b/app/shared/src/desktopMain/resources/backdrops/pexels-yunszyveli-12368637.jpg new file mode 100644 index 0000000..f8c3302 Binary files /dev/null and b/app/shared/src/desktopMain/resources/backdrops/pexels-yunszyveli-12368637.jpg differ diff --git a/backdrops/pexels-bogdankrupin-12049700.jpg b/backdrops/pexels-bogdankrupin-12049700.jpg new file mode 100644 index 0000000..dc460cb Binary files /dev/null and b/backdrops/pexels-bogdankrupin-12049700.jpg differ diff --git a/backdrops/pexels-bosichong-27940302.jpg b/backdrops/pexels-bosichong-27940302.jpg new file mode 100644 index 0000000..87930cc Binary files /dev/null and b/backdrops/pexels-bosichong-27940302.jpg differ diff --git a/backdrops/pexels-farhan-najeer-644774196-32490483.jpg b/backdrops/pexels-farhan-najeer-644774196-32490483.jpg new file mode 100644 index 0000000..9afb6f9 Binary files /dev/null and b/backdrops/pexels-farhan-najeer-644774196-32490483.jpg differ diff --git a/backdrops/pexels-soubhagya23-8060218.jpg b/backdrops/pexels-soubhagya23-8060218.jpg new file mode 100644 index 0000000..1651100 Binary files /dev/null and b/backdrops/pexels-soubhagya23-8060218.jpg differ diff --git a/backdrops/pexels-steve-29390703.jpg b/backdrops/pexels-steve-29390703.jpg new file mode 100644 index 0000000..99f1c88 Binary files /dev/null and b/backdrops/pexels-steve-29390703.jpg differ diff --git a/backdrops/pexels-yunszyveli-12368637.jpg b/backdrops/pexels-yunszyveli-12368637.jpg new file mode 100644 index 0000000..f8c3302 Binary files /dev/null and b/backdrops/pexels-yunszyveli-12368637.jpg differ