diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt index 5f77bbc..8b8a495 100644 --- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt +++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt @@ -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) } } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index f0908a8..968eaa0 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -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(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(null) } var deleteThread by remember { mutableStateOf(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) + } + } } }