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:
1 parent
0e8f69a43c
commit
37c13b4d02
2 files changed
+117
-166
No files matched your search
@@ -92,8 +92,8 @@ fun IrisApp(
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// StatusChip + connection banner show the link state without
|
||||
// blocking the view (M7's full-screen spinner is gone).
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -113,6 +120,7 @@ import iris.ui.theme.contrastText
|
||||
import iris.util.formatDayLabel
|
||||
import iris.util.formatTime
|
||||
import iris.util.localDayKey
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -143,6 +151,14 @@ fun ChatScreen(controller: IrisController) {
|
||||
var showNewThread by remember { mutableStateOf(false) }
|
||||
var showPicker 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 banners by controller.banners.collectAsState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -217,10 +233,6 @@ fun ChatScreen(controller: IrisController) {
|
||||
// M6: the chat column (header + list + composer), shared by both layouts.
|
||||
@Composable
|
||||
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.
|
||||
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
@@ -253,97 +265,38 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header (M7: avatar + two-line title pill, reference look). Wide
|
||||
// panes keep everything on one row; narrow phones move the utility
|
||||
// controls to a second row so the pill stays dominant.
|
||||
// Header: title pill on the left, status bubble + search on the
|
||||
// right, single row on both wide and narrow layouts.
|
||||
val channelName = currentChannel?.name ?: "Iris"
|
||||
BoxWithConstraints {
|
||||
val wide = maxWidth.value >= 600f
|
||||
if (wide) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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 },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showHamburger) {
|
||||
HeaderIconButton(onClick = onOpenDrawer) {
|
||||
Text("☰", fontSize = 18.sp)
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Row(
|
||||
}
|
||||
TitlePill(channelName, Modifier.weight(1f))
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (showInspector) IrisColors.chipSelected else Color.Transparent,
|
||||
)
|
||||
.padding(2.dp),
|
||||
) {
|
||||
if (showHamburger) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
renameThread?.let { t ->
|
||||
NameDialog(
|
||||
@@ -728,6 +650,8 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
}
|
||||
|
||||
// Status bubble tap → toast with the current connection status.
|
||||
ToastOverlay(toastMessage)
|
||||
|
||||
// File picker overlay (M4)
|
||||
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). */
|
||||
@Composable
|
||||
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
|
||||
private fun StatusChip(state: GatewayClient.State) {
|
||||
val (label, color) = when (state) {
|
||||
GatewayClient.State.Disconnected -> "offline" to IrisColors.statusGrey
|
||||
GatewayClient.State.Connecting -> "connecting…" to IrisColors.statusAmber
|
||||
GatewayClient.State.Reconnecting -> "reconnecting…" to IrisColors.statusAmber
|
||||
is GatewayClient.State.Connected -> "connected" to IrisColors.statusGreen
|
||||
is GatewayClient.State.AuthFailed -> "auth failed" to IrisColors.statusRed
|
||||
private fun StatusBubble(state: GatewayClient.State, onClick: () -> Unit) {
|
||||
val (color, pulsing) = when (state) {
|
||||
is GatewayClient.State.Connected -> IrisColors.statusGreen to false
|
||||
GatewayClient.State.Connecting,
|
||||
GatewayClient.State.Reconnecting -> IrisColors.statusAmber to true
|
||||
GatewayClient.State.Disconnected,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(color.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
.padding(7.dp)
|
||||
.clip(CircleShape)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user