Added default backdrops to the chat.
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user