Added default backdrops to the chat.

This commit is contained in:
ARIA committed 2026-08-21 15:48:30 +02:00
1 parent 96b60daf08
commit 75d491fd30
33 files changed
+1738 -977

No files matched your search

@@ -1,4 +1,4 @@
package iris.platform
/** True on the desktop target (M6: big-screen layout, shortcuts, tray). */
expect val isDesktop: Boolean
expect val isDesktop: Boolean
@@ -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)
expect fun openDocument(
path: String,
mime: String,
)
@@ -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> = _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<UserTheme> = _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<List<PendingAttachment>> = _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<MessageStopPayload>()?.let {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
}
TYPE_MESSAGE ->
}
TYPE_MESSAGE -> {
frame.payloadAs<MessagePayload>()?.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<ChannelDeletedPayload>()?.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<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false
}
TYPE_COMMANDS_CATALOG -> {
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
}
TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.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<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
@@ -444,12 +498,15 @@ class IrisController(
}
}
}
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.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<StatusPayload>()?.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<PendingAttachment> = emptyList()) {
* M4: [attachments] (uploaded) are attached via media_refs. */
fun send(
text: String,
attachments: List<PendingAttachment> = 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<Unit> {
suspend fun connect(
url: String,
token: String,
): Result<Unit> {
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
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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<ImageBitmap?>(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<ImageBitmap?>(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('\\') }
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -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://<id>` (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<Backdrop> =
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
}
}
@@ -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://<id>`) 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<ImageBitmap?>(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
}
}