android: shrink composer pill; float it over the message list
- reduce composer size (padding, corner radius, send button 40->36dp) - move composer plane (back-to-bottom button, attachments, banners, slash drawer, input pill) into a bottom overlay so messages scroll behind it; overlay height drives the list's bottom content padding
This commit is contained in:
1 parent
7f0bdcbbc1
commit
8fba00ba7f
1 file changed
+286
-252
@@ -69,6 +69,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -91,6 +92,7 @@ import androidx.compose.ui.input.key.key
|
|||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -474,14 +476,26 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages (M7: interleaved date separators; bubbles capped at 85% width)
|
// Messages (M7: interleaved date separators; bubbles capped at 85% width).
|
||||||
|
// The composer plane (back-to-bottom button, attachments, banners, slash
|
||||||
|
// drawer, input pill) is an OVERLAY at the bottom of this area: the list
|
||||||
|
// scrolls behind it, so the pill floats over the wallpaper instead of
|
||||||
|
// sitting in a hard-edged gap. The overlay's measured height becomes the
|
||||||
|
// list's bottom content padding, keeping the newest message visible above it.
|
||||||
|
var overlayHeightPx by remember { mutableIntStateOf(0) }
|
||||||
val rows = remember(items) { buildChatRows(items) }
|
val rows = remember(items) { buildChatRows(items) }
|
||||||
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
val bubbleMaxWidth = (maxWidth.value * 0.85f).dp
|
val bubbleMaxWidth = (maxWidth.value * 0.85f).dp
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding =
|
||||||
|
PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
top = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
bottom = (overlayHeightPx / LocalDensity.current.density).dp + 16.dp,
|
||||||
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty() && !typing) {
|
if (items.isEmpty() && !typing) {
|
||||||
@@ -552,289 +566,309 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back-to-bottom button: appears once the user has scrolled up
|
// Composer plane (overlay): back-to-bottom button, attachments, banners,
|
||||||
// to read history; a tap glides the view back to the latest
|
// slash drawer and the input pill. The list scrolls behind it; its
|
||||||
// message (smooth but quick).
|
// measured height is the list's bottom content padding (above).
|
||||||
if (!isAtBottom && !isAutoScrolling) {
|
|
||||||
val alpha = remember { Animatable(0f) }
|
|
||||||
LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) }
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 16.dp, bottom = 12.dp)
|
|
||||||
.alpha(alpha.value)
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(IrisColors.surface)
|
|
||||||
.border(1.dp, IrisColors.divider, CircleShape)
|
|
||||||
.clickable {
|
|
||||||
drawerScope.launch { scrollToBottom(animate = true) }
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
|
||||||
contentDescription = "Scroll to latest",
|
|
||||||
tint = IrisColors.textSecondary,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending attachments (M4; hidden in automation channels — no composer)
|
|
||||||
if (attachments.isNotEmpty() && !isAutomation) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
attachments.forEach { att ->
|
|
||||||
AttachmentChip(att, onRemove = { controller.removeAttachment(att.filename) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// M5: notification banners (above the composer)
|
|
||||||
if (banners.isNotEmpty()) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
.onSizeChanged { overlayHeightPx = it.height },
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
) {
|
||||||
banners.forEach { b ->
|
// Back-to-bottom button: appears once the user has scrolled up
|
||||||
NotificationBanner(
|
// to read history; a tap glides the view back to the latest
|
||||||
banner = b,
|
// message (smooth but quick).
|
||||||
onOpen = {
|
if (!isAtBottom && !isAutoScrolling) {
|
||||||
controller.openDeepLink(b.chatId, b.threadId)
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||||
controller.dismissBanner(b.id)
|
val alpha = remember { Animatable(0f) }
|
||||||
},
|
LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) }
|
||||||
onDismiss = { controller.dismissBanner(b.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// M7: connection-state banner (persistent, above the composer; no dismiss).
|
|
||||||
val gatewayStatus by controller.gatewayStatus.collectAsState()
|
|
||||||
val connBanner =
|
|
||||||
when {
|
|
||||||
state is GatewayClient.State.Connecting -> {
|
|
||||||
"Connecting to gateway…"
|
|
||||||
}
|
|
||||||
|
|
||||||
state is GatewayClient.State.Reconnecting -> {
|
|
||||||
"Reconnecting to gateway… messages will sync automatically when the connection is back."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: gatewayStatus never becomes "restarting" — that
|
|
||||||
// state posts a chat notice immediately (IrisController,
|
|
||||||
// TYPE_STATUS) instead of showing a banner.
|
|
||||||
|
|
||||||
gatewayStatus == "degraded" -> {
|
|
||||||
"Gateway reports a degraded state — replies may be slow or unavailable."
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (connBanner != null) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(IrisColors.panel)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(connBanner, fontSize = 12.sp, color = IrisColors.textTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slash-command drawer: rolls up over the composer while the input is a
|
|
||||||
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
|
|
||||||
// row sends the command (and closes the drawer).
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = slashDrawerOpen && !isAutomation && !selectionMode,
|
|
||||||
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
|
|
||||||
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(IrisColors.panel)
|
|
||||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
|
|
||||||
) {
|
|
||||||
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
|
|
||||||
if (index > 0) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.padding(end = 16.dp, bottom = 8.dp)
|
||||||
.height(1.dp)
|
.alpha(alpha.value)
|
||||||
.background(IrisColors.divider),
|
.size(40.dp)
|
||||||
)
|
.clip(CircleShape)
|
||||||
|
.background(IrisColors.surface)
|
||||||
|
.border(1.dp, IrisColors.divider, CircleShape)
|
||||||
|
.clickable {
|
||||||
|
drawerScope.launch { scrollToBottom(animate = true) }
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = "Scroll to latest",
|
||||||
|
tint = IrisColors.textSecondary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending attachments (M4; hidden in automation channels — no composer)
|
||||||
|
if (attachments.isNotEmpty() && !isAutomation) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onSlashPick(cmd) }
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
attachments.forEach { att ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
AttachmentChip(att, onRemove = { controller.removeAttachment(att.filename) })
|
||||||
Text(
|
}
|
||||||
cmd.name,
|
}
|
||||||
color = IrisColors.primary,
|
}
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 14.sp,
|
// M5: notification banners (above the composer)
|
||||||
|
if (banners.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
banners.forEach { b ->
|
||||||
|
NotificationBanner(
|
||||||
|
banner = b,
|
||||||
|
onOpen = {
|
||||||
|
controller.openDeepLink(b.chatId, b.threadId)
|
||||||
|
controller.dismissBanner(b.id)
|
||||||
|
},
|
||||||
|
onDismiss = { controller.dismissBanner(b.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M7: connection-state banner (persistent, above the composer; no dismiss).
|
||||||
|
val gatewayStatus by controller.gatewayStatus.collectAsState()
|
||||||
|
val connBanner =
|
||||||
|
when {
|
||||||
|
state is GatewayClient.State.Connecting -> {
|
||||||
|
"Connecting to gateway…"
|
||||||
|
}
|
||||||
|
|
||||||
|
state is GatewayClient.State.Reconnecting -> {
|
||||||
|
"Reconnecting to gateway… messages will sync automatically when the connection is back."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: gatewayStatus never becomes "restarting" — that
|
||||||
|
// state posts a chat notice immediately (IrisController,
|
||||||
|
// TYPE_STATUS) instead of showing a banner.
|
||||||
|
|
||||||
|
gatewayStatus == "degraded" -> {
|
||||||
|
"Gateway reports a degraded state — replies may be slow or unavailable."
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connBanner != null) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(IrisColors.panel)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(connBanner, fontSize = 12.sp, color = IrisColors.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash-command drawer: rolls up over the composer while the input is a
|
||||||
|
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
|
||||||
|
// row sends the command (and closes the drawer).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = slashDrawerOpen && !isAutomation && !selectionMode,
|
||||||
|
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
|
||||||
|
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(IrisColors.panel)
|
||||||
|
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
|
||||||
|
) {
|
||||||
|
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
|
||||||
|
if (index > 0) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(IrisColors.divider),
|
||||||
)
|
)
|
||||||
if (cmd.argsHint.isNotEmpty()) {
|
}
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSlashPick(cmd) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
cmd.name,
|
||||||
|
color = IrisColors.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
if (cmd.argsHint.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
" ${cmd.argsHint}",
|
||||||
|
color = IrisColors.textTertiary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
" ${cmd.argsHint}",
|
cmd.description,
|
||||||
color = IrisColors.textTertiary,
|
color = IrisColors.textSecondary,
|
||||||
fontSize = 13.sp,
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
cmd.description,
|
|
||||||
color = IrisColors.textSecondary,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection toolbar (replaces the composer while messages are selected).
|
// Selection toolbar (replaces the composer while messages are selected).
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
SelectionToolbar(
|
SelectionToolbar(
|
||||||
count = selectedIds.size,
|
count = selectedIds.size,
|
||||||
onCancel = { exitSelection() },
|
onCancel = { exitSelection() },
|
||||||
onDelete = { showDeleteConfirm = true },
|
onDelete = { showDeleteConfirm = true },
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composer (M7: rounded pill + accent circular send button). Sending is
|
|
||||||
// NOT gated on a live connection: an offline send stays queued
|
|
||||||
// (Pending) and is auto-resent on the next (re)connect (see
|
|
||||||
// IrisController.onSendResult / reconcileFailedSends). The
|
|
||||||
// automation lane stays read-only regardless.
|
|
||||||
// docs/19 §19.15 (v2): media works over the HTTP leg too, so
|
|
||||||
// attachments are sendable in both connected states (uploading
|
|
||||||
// still needs a live connection — the picker stays gated).
|
|
||||||
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
|
|
||||||
val layoutDensity = LocalDensity.current.density
|
|
||||||
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
|
||||||
if (isAutomation) {
|
|
||||||
// Read-only notice in place of the composer.
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(IrisColors.panel)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textTertiary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (!selectionMode) {
|
// Composer (M7: rounded pill + accent circular send button). Sending is
|
||||||
Row(
|
// NOT gated on a live connection: an offline send stays queued
|
||||||
modifier =
|
// (Pending) and is auto-resent on the next (re)connect (see
|
||||||
Modifier
|
// IrisController.onSendResult / reconcileFailedSends). The
|
||||||
.fillMaxWidth()
|
// automation lane stays read-only regardless.
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
// docs/19 §19.15 (v2): media works over the HTTP leg too, so
|
||||||
.clip(RoundedCornerShape(28.dp))
|
// attachments are sendable in both connected states (uploading
|
||||||
.background(IrisColors.surface)
|
// still needs a live connection — the picker stays gated).
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val layoutDensity = LocalDensity.current.density
|
||||||
) {
|
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
Row(
|
if (isAutomation) {
|
||||||
modifier =
|
// Read-only notice in place of the composer.
|
||||||
Modifier
|
Box(
|
||||||
.weight(1f)
|
modifier =
|
||||||
.padding(horizontal = 6.dp, vertical = 10.dp),
|
Modifier
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.fillMaxWidth()
|
||||||
) {
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
contentAlignment = Alignment.Center,
|
||||||
BasicTextField(
|
) {
|
||||||
value = input,
|
Box(
|
||||||
onValueChange = { input = it },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified)
|
.background(IrisColors.panel)
|
||||||
.focusRequester(composerFocusRequester)
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
.onFocusChanged { composerFocused = it.isFocused },
|
) {
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
|
||||||
keyboardActions = KeyboardActions(onSend = { doSend() }),
|
|
||||||
onTextLayout = { result ->
|
|
||||||
if (result.lineCount > 0) {
|
|
||||||
val totalHeight = result.getLineBottom(result.lineCount - 1)
|
|
||||||
val lineHeight = totalHeight / result.lineCount
|
|
||||||
textHeightPx = minOf(totalHeight, lineHeight * 5f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (input.isEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
"Message",
|
"⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
|
||||||
color = IrisColors.textDim,
|
fontSize = 12.sp,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
color = IrisColors.textTertiary,
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
|
} else if (!selectionMode) {
|
||||||
Text("📎", fontSize = 18.sp)
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(IrisColors.surface)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
BasicTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(
|
||||||
|
max =
|
||||||
|
if (textHeightPx >
|
||||||
|
0f
|
||||||
|
) {
|
||||||
|
(textHeightPx / layoutDensity).dp
|
||||||
|
} else {
|
||||||
|
Dp.Unspecified
|
||||||
|
},
|
||||||
|
).focusRequester(composerFocusRequester)
|
||||||
|
.onFocusChanged { composerFocused = it.isFocused },
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { doSend() }),
|
||||||
|
onTextLayout = { result ->
|
||||||
|
if (result.lineCount > 0) {
|
||||||
|
val totalHeight = result.getLineBottom(result.lineCount - 1)
|
||||||
|
val lineHeight = totalHeight / result.lineCount
|
||||||
|
textHeightPx = minOf(totalHeight, lineHeight * 5f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Message",
|
||||||
|
color = IrisColors.textDim,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
|
||||||
|
Text("📎", fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
|
||||||
|
.clickable(enabled = canSend) { doSend() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("➤", color = Color.White, fontSize = 14.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
|
|
||||||
.clickable(enabled = canSend) { doSend() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text("➤", color = Color.White, fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user