Compact single-row header: tappable status bubble (green/yellow-pulsing/red) + search, status toast on tap; removed overflow menu (rename/forget)

This commit is contained in:
ARIA committed 2026-08-20 19:33:45 +02:00
1 parent c668b209b2
commit ccc40213ca
2 files changed
+117 -166

No files matched your search

@@ -92,8 +92,8 @@ fun IrisApp(
initialError = "Pairing rejected: ${s.message}", initialError = "Pairing rejected: ${s.message}",
) )
// Connecting / Reconnecting / Connected all render the chat; the header // Connecting / Reconnecting / Connected all render the chat; the header
// StatusChip + connection banner show the link state without // status bubble + connection banner show the link state
// blocking the view (M7's full-screen spinner is gone). // without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller) else -> ChatScreen(controller)
} }
} }
@@ -1,5 +1,12 @@
package iris.ui.screens package iris.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -113,6 +120,7 @@ import iris.ui.theme.contrastText
import iris.util.formatDayLabel import iris.util.formatDayLabel
import iris.util.formatTime import iris.util.formatTime
import iris.util.localDayKey import iris.util.localDayKey
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -143,6 +151,14 @@ fun ChatScreen(controller: IrisController) {
var showNewThread by remember { mutableStateOf(false) } var showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
// Status bubble tap → transient toast with the current connection status.
var toastMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(toastMessage) {
if (toastMessage != null) {
delay(2000)
toastMessage = null
}
}
val attachments by controller.attachments.collectAsState() val attachments by controller.attachments.collectAsState()
val banners by controller.banners.collectAsState() val banners by controller.banners.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -217,10 +233,6 @@ fun ChatScreen(controller: IrisController) {
// M6: the chat column (header + list + composer), shared by both layouts. // M6: the chat column (header + list + composer), shared by both layouts.
@Composable @Composable
fun ChatContent(showHamburger: Boolean, onOpenDrawer: () -> Unit) { fun ChatContent(showHamburger: Boolean, onOpenDrawer: () -> Unit) {
// M7: header overflow actions (rename / forget pairing).
var showOverflow by remember { mutableStateOf(false) }
var showRename by remember { mutableStateOf(false) }
var showForget by remember { mutableStateOf(false) }
// Topic context menu (long-press / right-click): rename / delete a thread. // Topic context menu (long-press / right-click): rename / delete a thread.
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) } var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) } var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
@@ -253,97 +265,38 @@ fun ChatScreen(controller: IrisController) {
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header (M7: avatar + two-line title pill, reference look). Wide // Header: title pill on the left, status bubble + search on the
// panes keep everything on one row; narrow phones move the utility // right, single row on both wide and narrow layouts.
// controls to a second row so the pill stays dominant.
val channelName = currentChannel?.name ?: "Iris" val channelName = currentChannel?.name ?: "Iris"
BoxWithConstraints { Row(
val wide = maxWidth.value >= 600f modifier = Modifier
if (wide) { .fillMaxWidth()
Row( .padding(horizontal = 8.dp, vertical = 6.dp),
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.fillMaxWidth() ) {
.padding(horizontal = 8.dp, vertical = 6.dp), if (showHamburger) {
verticalAlignment = Alignment.CenterVertically, HeaderIconButton(onClick = onOpenDrawer) {
) { Text("☰", fontSize = 18.sp)
if (showHamburger) {
HeaderIconButton(onClick = onOpenDrawer) {
Text("☰", fontSize = 18.sp)
}
}
TitlePill(channelName, Modifier.weight(1f))
StatusChip(state)
Spacer(modifier = Modifier.width(4.dp))
HeaderIconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp)
}
if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
if (showInspector) IrisColors.chipSelected else Color.Transparent,
)
.padding(2.dp),
) {
Text("ⓘ", fontSize = 16.sp)
}
}
}
HeaderOverflow(
expanded = showOverflow,
onToggle = { showOverflow = !showOverflow },
onRename = { showRename = true },
onForget = { showForget = true },
)
} }
} else { }
Column { TitlePill(channelName, Modifier.weight(1f))
Row( Spacer(modifier = Modifier.width(6.dp))
StatusBubble(state) { toastMessage = statusToastText(state) }
Spacer(modifier = Modifier.width(4.dp))
HeaderIconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp)
}
if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .clip(RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 6.dp), .background(
verticalAlignment = Alignment.CenterVertically, if (showInspector) IrisColors.chipSelected else Color.Transparent,
)
.padding(2.dp),
) { ) {
if (showHamburger) { Text("ⓘ", fontSize = 16.sp)
HeaderIconButton(onClick = onOpenDrawer) {
Text("☰", fontSize = 18.sp)
}
}
TitlePill(channelName, Modifier.weight(1f))
HeaderOverflow(
expanded = showOverflow,
onToggle = { showOverflow = !showOverflow },
onRename = { showRename = true },
onForget = { showForget = true },
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
StatusChip(state)
HeaderIconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp)
}
if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
if (showInspector) IrisColors.chipSelected else Color.Transparent,
)
.padding(2.dp),
) {
Text("ⓘ", fontSize = 16.sp)
}
}
}
} }
} }
} }
@@ -571,37 +524,6 @@ fun ChatScreen(controller: IrisController) {
} }
} }
// M7: overflow actions (rename / forget pairing)
if (showRename) {
NameDialog(
title = "Rename channel",
onConfirm = { name ->
controller.renameChannel(currentChatId, name)
showRename = false
},
onDismiss = { showRename = false },
initialName = currentChannel?.name.orEmpty(),
confirmLabel = "Rename",
)
}
if (showForget) {
AlertDialog(
onDismissRequest = { showForget = false },
title = { Text("Forget pairing?") },
text = {
Text("This device will be unpaired from the gateway. You'll need the pairing token to connect again.")
},
confirmButton = {
TextButton(onClick = {
showForget = false
controller.forget()
}) { Text("Forget") }
},
dismissButton = {
TextButton(onClick = { showForget = false }) { Text("Cancel") }
},
)
}
// Topic context menu: rename a thread. // Topic context menu: rename a thread.
renameThread?.let { t -> renameThread?.let { t ->
NameDialog( NameDialog(
@@ -728,6 +650,8 @@ fun ChatScreen(controller: IrisController) {
} }
} }
// Status bubble tap → toast with the current connection status.
ToastOverlay(toastMessage)
// File picker overlay (M4) // File picker overlay (M4)
if (showPicker) { if (showPicker) {
@@ -919,37 +843,6 @@ internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Un
} }
} }
/** M7: header overflow ⋮ — rename channel / forget pairing. */
@Composable
private fun HeaderOverflow(
expanded: Boolean,
onToggle: () -> Unit,
onRename: () -> Unit,
onForget: () -> Unit,
) {
HeaderIconButton(onClick = onToggle) {
Text("⋮", fontSize = 18.sp)
}
if (expanded) {
DropdownMenu(expanded = expanded, onDismissRequest = onToggle) {
DropdownMenuItem(
text = { Text("Rename channel") },
onClick = {
onToggle()
onRename()
},
)
DropdownMenuItem(
text = { Text("Forget pairing") },
onClick = {
onToggle()
onForget()
},
)
}
}
}
/** M7: circle with the first letter of [name] (header / rail / drawer). */ /** M7: circle with the first letter of [name] (header / rail / drawer). */
@Composable @Composable
private fun LetterAvatar(name: String, size: Dp, color: Color) { private fun LetterAvatar(name: String, size: Dp, color: Color) {
@@ -1467,22 +1360,80 @@ private fun NameDialog(
) )
} }
/** Human-readable connection status for the status-bubble tap toast. */
private fun statusToastText(state: GatewayClient.State): String = when (state) {
is GatewayClient.State.Connected -> "Connected to Hermes"
GatewayClient.State.Connecting -> "Connecting to Hermes"
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
is GatewayClient.State.AuthFailed -> "Unpaired from Hermes"
}
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
* red = not paired / offline. Tapping shows a toast with the status text. */
@Composable @Composable
private fun StatusChip(state: GatewayClient.State) { private fun StatusBubble(state: GatewayClient.State, onClick: () -> Unit) {
val (label, color) = when (state) { val (color, pulsing) = when (state) {
GatewayClient.State.Disconnected -> "offline" to IrisColors.statusGrey is GatewayClient.State.Connected -> IrisColors.statusGreen to false
GatewayClient.State.Connecting -> "connecting…" to IrisColors.statusAmber GatewayClient.State.Connecting,
GatewayClient.State.Reconnecting -> "reconnecting…" to IrisColors.statusAmber GatewayClient.State.Reconnecting -> IrisColors.statusAmber to true
is GatewayClient.State.Connected -> "connected" to IrisColors.statusGreen GatewayClient.State.Disconnected,
is GatewayClient.State.AuthFailed -> "auth failed" to IrisColors.statusRed is GatewayClient.State.AuthFailed -> IrisColors.statusRed to false
} }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulsing) {
if (pulsing) {
alpha.animateTo(
targetValue = 0.25f,
animationSpec = infiniteRepeatable(tween(700), RepeatMode.Reverse),
)
} else {
alpha.snapTo(1f)
}
}
// 10dp visual dot with a larger (24dp) transparent tap target.
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(10.dp)) .padding(7.dp)
.background(color.copy(alpha = 0.15f)) .clip(CircleShape)
.padding(horizontal = 8.dp, vertical = 3.dp), .clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { ) {
Text(label, fontSize = 11.sp, color = color) Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(color.copy(alpha = alpha.value)),
)
}
}
/** Transient bottom-center toast (status bubble tap). The full-screen host is
* transparent and non-interactive, so touches pass through to the chat. */
@Composable
private fun ToastOverlay(message: String?) {
var shown by remember { mutableStateOf("") }
LaunchedEffect(message) {
if (message != null) shown = message
}
Box(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(
visible = message != null,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
enter = fadeIn(tween(150)),
exit = fadeOut(tween(300)),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.82f))
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(shown, color = Color.White, fontSize = 13.sp)
}
}
} }
} }