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
+94
-143
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,13 +265,9 @@ 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 {
|
|
||||||
val wide = maxWidth.value >= 600f
|
|
||||||
if (wide) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -272,7 +280,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TitlePill(channelName, Modifier.weight(1f))
|
TitlePill(channelName, Modifier.weight(1f))
|
||||||
StatusChip(state)
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
StatusBubble(state) { toastMessage = statusToastText(state) }
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
HeaderIconButton(onClick = { showSearch = true }) {
|
HeaderIconButton(onClick = { showSearch = true }) {
|
||||||
Text("🔍", fontSize = 16.sp)
|
Text("🔍", fontSize = 16.sp)
|
||||||
@@ -291,62 +300,6 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HeaderOverflow(
|
|
||||||
expanded = showOverflow,
|
|
||||||
onToggle = { showOverflow = !showOverflow },
|
|
||||||
onRename = { showRename = true },
|
|
||||||
onForget = { showForget = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column {
|
|
||||||
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))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topic switcher (threads on)
|
// Topic switcher (threads on)
|
||||||
@@ -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(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
/** Human-readable connection status for the status-bubble tap toast. */
|
||||||
private fun StatusChip(state: GatewayClient.State) {
|
private fun statusToastText(state: GatewayClient.State): String = when (state) {
|
||||||
val (label, color) = when (state) {
|
is GatewayClient.State.Connected -> "Connected to Hermes"
|
||||||
GatewayClient.State.Disconnected -> "offline" to IrisColors.statusGrey
|
GatewayClient.State.Connecting -> "Connecting to Hermes"
|
||||||
GatewayClient.State.Connecting -> "connecting…" to IrisColors.statusAmber
|
GatewayClient.State.Reconnecting -> "Re-Connecting to Hermes"
|
||||||
GatewayClient.State.Reconnecting -> "reconnecting…" to IrisColors.statusAmber
|
GatewayClient.State.Disconnected -> "Unpaired from Hermes"
|
||||||
is GatewayClient.State.Connected -> "connected" to IrisColors.statusGreen
|
is GatewayClient.State.AuthFailed -> "Unpaired from Hermes"
|
||||||
is GatewayClient.State.AuthFailed -> "auth failed" to IrisColors.statusRed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header status bubble: green = connected, yellow pulsing = (re)connecting,
|
||||||
|
* red = not paired / offline. Tapping shows a toast with the status text. */
|
||||||
|
@Composable
|
||||||
|
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(
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user