M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)
This commit is contained in:
1 parent
0cc8b7aafe
commit
bf6bf7e8bd
26 files changed
+2225
-327
No files matched your search
@@ -1,16 +1,64 @@
|
||||
package iris.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import iris.data.SecureStore
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Android pairing storage. M1: SharedPreferences (dev). M5 moves the token
|
||||
* to EncryptedSharedPreferences per docs/10 §10.2.
|
||||
* Android pairing storage. M7: EncryptedSharedPreferences (MasterKey
|
||||
* AES256_GCM) per docs/09 §9.7. The M1/M5 plain "iris" SharedPreferences
|
||||
* 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 {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("iris", Context.MODE_PRIVATE)
|
||||
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,
|
||||
)
|
||||
|
||||
init {
|
||||
migratePlainValues()
|
||||
}
|
||||
|
||||
/** One-time migration of the M1/M5 plain values into the encrypted store. */
|
||||
private fun migratePlainValues() {
|
||||
val editor = prefs.edit()
|
||||
var migrated = false
|
||||
for (key in listOf(KEY_URL, KEY_TOKEN, KEY_DEVICE_ID, KEY_FCM_TOKEN, KEY_NTFY_TOPIC, KEY_NTFY_SERVER)) {
|
||||
val old = plainPrefs.getString(key, null)
|
||||
if (old != null && !prefs.contains(key)) {
|
||||
editor.putString(key, old)
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
val oldCursor = plainPrefs.getLong(KEY_SYNC_CURSOR, 0L)
|
||||
if (oldCursor != 0L && !prefs.contains(KEY_SYNC_CURSOR)) {
|
||||
editor.putLong(KEY_SYNC_CURSOR, oldCursor)
|
||||
migrated = true
|
||||
}
|
||||
if (migrated) editor.apply()
|
||||
// The plain store must not keep a copy of any value.
|
||||
plainPrefs.edit()
|
||||
.remove(KEY_URL)
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_DEVICE_ID)
|
||||
.remove(KEY_SYNC_CURSOR)
|
||||
.remove(KEY_FCM_TOKEN)
|
||||
.remove(KEY_NTFY_TOPIC)
|
||||
.remove(KEY_NTFY_SERVER)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override var serverUrl: String
|
||||
get() = prefs.getString(KEY_URL, "").orEmpty()
|
||||
@@ -59,6 +107,8 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PLAIN_PREFS_NAME = "iris"
|
||||
const val SECURE_PREFS_NAME = "iris_secure"
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package iris
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -11,21 +9,16 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
import iris.state.IrisController
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
|
||||
private val IrisDark = darkColorScheme(
|
||||
background = Color(0xFF1B1E28),
|
||||
surface = Color(0xFF222634),
|
||||
onBackground = Color(0xFFE8EAF0),
|
||||
onSurface = Color(0xFFE8EAF0),
|
||||
primary = Color(0xFF4F7CFF),
|
||||
)
|
||||
import iris.ui.screens.ConnectingScreen
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.util.hostFromUrl
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
@@ -57,8 +50,8 @@ fun IrisApp(
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
|
||||
MaterialTheme(colorScheme = IrisDark) {
|
||||
Surface(modifier = Modifier.background(IrisDark.background)) {
|
||||
IrisTheme {
|
||||
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||
val s = state
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
@@ -70,6 +63,9 @@ fun IrisApp(
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// M7: initial connect in flight — dedicated screen, not an empty chat.
|
||||
GatewayClient.State.Connecting ->
|
||||
ConnectingScreen(hostFromUrl(store.serverUrl))
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@ sealed interface ChatItem {
|
||||
val id: String
|
||||
}
|
||||
|
||||
/** Delivery status of a user message (M5: read.receipt; M7: failed sends). */
|
||||
enum class MsgStatus {
|
||||
Pending, // optimistic, not yet acknowledged by the gateway
|
||||
Sent, // gateway accepted it (echo received)
|
||||
Read, // agent received and started processing it (read.receipt)
|
||||
Failed, // send failed (error frame); tap the bubble to retry
|
||||
}
|
||||
|
||||
/** A chat message (user / assistant / commentary / streaming bubble). */
|
||||
data class MessageItem(
|
||||
override val id: String,
|
||||
@@ -49,6 +57,7 @@ data class MessageItem(
|
||||
val text: String,
|
||||
val ts: Long,
|
||||
val pending: Boolean = false,
|
||||
val status: MsgStatus = MsgStatus.Sent,
|
||||
val reasoning: String? = null,
|
||||
val isCommentary: Boolean = false,
|
||||
val streaming: Boolean = false,
|
||||
@@ -139,7 +148,7 @@ class ChatStore {
|
||||
localSeq++
|
||||
val id = "local_$localSeq"
|
||||
updateLane(lane) {
|
||||
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, media = media)
|
||||
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -176,6 +185,7 @@ class ChatStore {
|
||||
reasoning = p.reasoning,
|
||||
pending = false,
|
||||
streaming = false,
|
||||
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
ts = p.ts ?: cur.ts,
|
||||
@@ -382,6 +392,55 @@ class ChatStore {
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||
fun markRead(messageId: String) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||
item.status != MsgStatus.Read
|
||||
) {
|
||||
item.copy(pending = false, status = MsgStatus.Read)
|
||||
} else item
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||
fun failPending() {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||
item.copy(pending = false, status = MsgStatus.Failed)
|
||||
} else item
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: re-arm a failed user message for a retry send. */
|
||||
fun rearmForRetry(lane: String, messageId: String) {
|
||||
updateLane(lane) { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
|
||||
item.copy(pending = true, status = MsgStatus.Pending)
|
||||
} else item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_lanes.value = emptyMap()
|
||||
}
|
||||
|
||||
@@ -217,7 +217,10 @@ class GatewayClient(
|
||||
}
|
||||
TYPE_ERROR -> {
|
||||
val err = frame.payloadAs<ErrorPayload>()
|
||||
authError.complete(err?.message ?: "auth failed")
|
||||
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
|
||||
// M7: post-connect error frames are app events, not
|
||||
// auth failures — let the controller react.
|
||||
_events.tryEmit(frame)
|
||||
}
|
||||
TYPE_PONG -> Unit
|
||||
else -> {
|
||||
|
||||
@@ -54,9 +54,11 @@ const val TYPE_MEDIA_OFFER = "media.offer"
|
||||
const val TYPE_MEDIA_PULL = "media.pull"
|
||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
// M5 — push / notifications
|
||||
// M5 — push / notifications / read receipt / gateway status
|
||||
const val TYPE_NOTIFICATION = "notification"
|
||||
const val TYPE_FCM_REGISTER = "fcm.register"
|
||||
const val TYPE_READ_RECEIPT = "read.receipt"
|
||||
const val TYPE_STATUS = "status"
|
||||
|
||||
// M3 — channels / threads / search / sync
|
||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||
@@ -383,6 +385,16 @@ data class FcmRegisterPayload(
|
||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||
)
|
||||
|
||||
// ── M5: read receipt / gateway status (server -> app) ─────────────────────
|
||||
|
||||
@Serializable
|
||||
data class ReadReceiptPayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusPayload(val state: String)
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
fun helloFrame(
|
||||
|
||||
@@ -3,6 +3,8 @@ package iris.state
|
||||
import iris.data.ChatStore
|
||||
import iris.data.ChannelStore
|
||||
import iris.data.MediaItem
|
||||
import iris.data.MessageItem
|
||||
import iris.data.MsgStatus
|
||||
import iris.data.SecureStore
|
||||
import iris.media.MediaCache
|
||||
import iris.media.kindFromMime
|
||||
@@ -16,10 +18,13 @@ import iris.protocol.MediaOfferPayload
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.NotificationPayload
|
||||
import iris.protocol.ReadReceiptPayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SearchResultsPayload
|
||||
import iris.protocol.StatusPayload
|
||||
import iris.protocol.SyncDonePayload
|
||||
import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_NOTIFICATION
|
||||
import iris.protocol.TYPE_CHANNEL_CREATED
|
||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||
@@ -31,7 +36,9 @@ import iris.protocol.TYPE_MESSAGE
|
||||
import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_READ_RECEIPT
|
||||
import iris.protocol.TYPE_SEARCH_RESULTS
|
||||
import iris.protocol.TYPE_STATUS
|
||||
import iris.protocol.TYPE_SYNC_DONE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
@@ -89,6 +96,10 @@ class IrisController(
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||
|
||||
/** Gateway health state (M5: status frame; null = never received). */
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||
|
||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||
private val _threadsEnabled = MutableStateFlow(false)
|
||||
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
|
||||
@@ -259,6 +270,18 @@ class IrisController(
|
||||
TYPE_TYPING -> {
|
||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||
}
|
||||
TYPE_READ_RECEIPT -> {
|
||||
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -375,6 +398,16 @@ class IrisController(
|
||||
_attachments.value = emptyList()
|
||||
}
|
||||
|
||||
/** M7: resend a failed user message (tap on the failed bubble). */
|
||||
fun retrySend(messageId: String) {
|
||||
val lane = chat.currentLane.value
|
||||
val (chatId, threadId) = chat.parseLane(lane)
|
||||
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
|
||||
if (item.status != MsgStatus.Failed) return
|
||||
chat.rearmForRetry(lane, messageId)
|
||||
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId })
|
||||
}
|
||||
|
||||
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
|
||||
|
||||
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,11 +10,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -24,10 +26,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -64,53 +68,61 @@ fun ConnectScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.surface)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(if (busy) "Testing connection…" else "Test & Connect")
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (busy) "Testing connection…" else "Test & Connect")
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
@@ -121,4 +133,22 @@ fun ConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** M7: shown while the initial connection is in flight (state == Connecting). */
|
||||
@Composable
|
||||
fun ConnectingScreen(host: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
"Connecting to $host…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package iris.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||
* from here instead of hard-coding literals.
|
||||
*/
|
||||
object IrisColors {
|
||||
// Base
|
||||
val background = Color(0xFF1B1E28)
|
||||
val surface = Color(0xFF222634)
|
||||
val onBackground = Color(0xFFE8EAF0)
|
||||
val onSurface = Color(0xFFE8EAF0)
|
||||
val primary = Color(0xFF4F7CFF)
|
||||
|
||||
// Text shades (brightest to dimmest)
|
||||
val textBright = Color(0xFFD7DBE5)
|
||||
val textTertiary = Color(0xFFC7CCD8)
|
||||
val textSecondary = Color(0xFFB9C0D0)
|
||||
val textMuted = Color(0xFF9AA3B5)
|
||||
val textDim = Color(0xFF8A93A6)
|
||||
|
||||
// Bubbles
|
||||
val bubbleUser = primary
|
||||
val bubbleAssistant = Color(0xFF2A2E3B)
|
||||
val bubbleCommentary = Color(0xFF23262F)
|
||||
|
||||
// Panels, chips, rows
|
||||
val panel = Color(0xFF20242E)
|
||||
val chip = Color(0xFF2A2E3B)
|
||||
val chipSelected = Color(0xFF2A3550)
|
||||
val rowSelected = Color(0xFF232838)
|
||||
val reasoningPanel = Color(0xFF1E212B)
|
||||
val divider = Color(0xFF2A2E3B)
|
||||
|
||||
// Status
|
||||
val statusGrey = Color(0xFF9E9E9E)
|
||||
val statusAmber = Color(0xFFFFC107)
|
||||
val statusGreen = Color(0xFF4CAF50)
|
||||
val statusRed = Color(0xFFF44336)
|
||||
|
||||
// M7: failed-send hint on the accent user bubble
|
||||
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,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun IrisTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = IrisDark) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic pastel avatar color from a channel name (M7 rail/drawer). */
|
||||
fun avatarColor(name: String): Color {
|
||||
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
|
||||
return Color.hsv(hue, 0.5f, 0.75f)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package iris.util
|
||||
|
||||
/** Locale-aware day label for chat date separators (M7), e.g. "7. August". */
|
||||
expect fun formatDayLabel(epochMillis: Long): String
|
||||
|
||||
/** Locale-aware "HH:mm" time for bubble timestamps (M7). */
|
||||
expect fun formatTime(epochMillis: Long): String
|
||||
|
||||
/** Local calendar-day key used to detect date changes between messages (M7). */
|
||||
expect fun localDayKey(epochMillis: Long): String
|
||||
|
||||
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
|
||||
fun hostFromUrl(url: String): String {
|
||||
val noScheme = url.trim().substringAfter("://")
|
||||
return noScheme.substringBefore("/").ifBlank { url.trim() }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package iris.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
// jvmMain is the intermediate source set for both androidMain (minSdk 29,
|
||||
// java.time available) and desktopMain, so one actual covers both targets.
|
||||
|
||||
actual fun formatDayLabel(epochMillis: Long): String {
|
||||
if (epochMillis <= 0) return ""
|
||||
val zone = ZoneId.systemDefault()
|
||||
val date = Instant.ofEpochMilli(epochMillis).atZone(zone)
|
||||
val now = Instant.now().atZone(zone)
|
||||
val pattern = if (date.year == now.year) "d. MMMM" else "d. MMMM, yyyy"
|
||||
return DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(date)
|
||||
}
|
||||
|
||||
actual fun formatTime(epochMillis: Long): String {
|
||||
if (epochMillis <= 0) return ""
|
||||
return DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault())
|
||||
.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
|
||||
}
|
||||
|
||||
actual fun localDayKey(epochMillis: Long): String =
|
||||
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
|
||||
Reference in new issue
Block a user