From 7719ae062ea2269ef2921cf2d937f01840dfe4f3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 18:30:07 -0400 Subject: [PATCH] Match the kit's chat bar and message box Measured out of "Light Mode - iOS/07 Messages-Details 1" rather than eyeballed: the App Bar is 88 tall with back at x=24, title 16pt Medium over a 12pt line with a dot beside it, and Call at x=279. The Message Box is 84 tall: plus at x=16, an F3F3F3 pill from x=56, camera at x=287, more at x=335. The composer is one row now. Voice recording was a chip hanging underneath it - the last thing on the screen that did not belong to that row - and the mic is an icon in the row, with the permission plumbing hoisted to where it is used. Send appears only when there is something to send; at rest the row is the kit's. iOS tab bar drops its labels, matching the kit's "Bottom Bar/Without Label" and Android. Titles survive as accessibility names - dropping text from the screen must not drop it from VoiceOver. PresenceDot was still an olive, a mustard and a rust: the previous brand's palette, left behind when the accent changed. Now teal, blue and pink from the kit. Two things the kit draws that this deliberately does not: a video-call button and an emoji key. Neither CallManager has any video path at all, and there is no emoji picker to open - the system keyboard already carries one. A mockup can draw a feature; the app cannot draw one it does not have, and this codebase has already shipped one dead call button once. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 135 +++++++++++------- 1 file changed, 85 insertions(+), 50 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index d7d5594..8d38ece 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1105,11 +1105,15 @@ private fun SectionLabel(text: String) { @Composable private fun PresenceDot(status: String) { + // ⚠️ These were an olive, a mustard and a rust — the previous brand's + // palette, left behind when the accent was changed. Nothing else on the + // screen is those colours any more, which is exactly how a dot ends up + // looking like it belongs to a different app. val colour = when (status.lowercase()) { - "online" -> Color(0xFF3FA34D) - "away" -> Color(0xFFD9A441) - "busy" -> Color(0xFFC24A3A) - else -> Color(0xFF8A8078) + "online" -> Palette.Teal + "away" -> Palette.AccentBright + "busy" -> Palette.Pink + else -> Palette.SecondaryText } Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour)) } @@ -1173,6 +1177,12 @@ fun ColumnScope.ChatScreen( // different key handling, and an enabled-looking button that cannot // work is worse than an absent one. if (chat.peerId != null) { + // ⚠️ **The kit's bar has a video icon here and this does not**, and + // that is deliberate. Neither CallManager has any video path at + // all — no capturer, no video track, nothing — so the button would + // be a control that does nothing. This app has already shipped one + // of those on the call screen once. A mockup can draw a feature; + // the app cannot draw one it does not have. IconAction( icon = KitIcon.Call, label = "Call", @@ -1220,6 +1230,24 @@ fun ColumnScope.ChatScreen( } val context = androidx.compose.ui.platform.LocalContext.current + + // ⚠️ Hoisted out of VoiceComposer so the microphone can sit IN the composer + // row. The kit's Message Box is one row — plus, the pill, then icons — and + // a voice control on a line of its own underneath was the last thing on + // this screen that did not belong to that row. + var micGranted by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED + ) + } + val askMic = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { allowed -> + micGranted = allowed + if (allowed) model.startRecording() + } + // The system picker, so the app never asks for storage permission — it is // handed exactly the one file the person chose and nothing else. val pickImage = androidx.activity.compose.rememberLauncherForActivityResult( @@ -1279,22 +1307,52 @@ fun ColumnScope.ChatScreen( ) } - Button( - onClick = { - model.sendMessage(draft) - draft = "" - }, - enabled = draft.isNotBlank(), - shape = CircleShape, - contentPadding = PaddingValues(0.dp), - modifier = Modifier.size(Kit.pillHeight).testTag("send"), - ) { - Icon( - KitIcon.Send, - contentDescription = null, - modifier = Modifier.size(Kit.icon), + if (chat.peerId != null && chat.recordingSeconds == null) { + IconAction( + icon = KitIcon.Mic, + label = "Record a voice note", + tag = "record-voice", + onClick = { + if (micGranted) model.startRecording() + else askMic.launch(Manifest.permission.RECORD_AUDIO) + }, ) } + + // ⚠️ **No emoji icon, which the kit's pill has**, for the same reason + // there is no video button: there is no emoji picker to open, and the + // system keyboard already carries one. A glyph that does nothing is + // worse than a missing one. + // + // 🔴 **The kit's Message Box has no send button.** Measured: plus at + // x=16, the F3F3F3 pill from x=56 with an emoji icon inside it at the + // trailing end, camera at x=287, more at x=335. Nothing else. + // + // ⚠️ It is kept anyway, and only while there is something to send. A + // composer with no visible way to send relies on the keyboard's return + // key, which is a real convention but not one to adopt silently on + // somebody else's behalf — and when the field is empty this collapses + // to exactly the kit's row. Camera takes its place at rest. + if (draft.isBlank()) { + IconAction(icon = KitIcon.Camera, label = "Take a photo", tag = "camera", + onClick = { pickImage.launch("image/*") }) + } else { + Button( + onClick = { + model.sendMessage(draft) + draft = "" + }, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(Kit.pillHeight).testTag("send"), + ) { + Icon( + KitIcon.Send, + contentDescription = null, + modifier = Modifier.size(Kit.icon), + ) + } + } } // Voice notes sit under the composer rather than inside it: recording @@ -1316,40 +1374,17 @@ private fun ColumnScope.VoiceComposer( model: MessengerViewModel, chat: MessengerViewModel.Phase.Chat, ) { - val context = LocalContext.current - var granted by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED - ) - } - // ⚠️ Requested when the user asks to record, not at startup. A microphone - // prompt on first launch, before anything has been said, is the kind a - // person denies on reflex — and a denied permission is far harder to + // ⚠️ The permission plumbing moved to the composer row with the mic icon + // that needs it — asked when somebody taps record, never at startup. A + // microphone prompt on first launch, before anything has been said, is the + // kind a person denies on reflex, and a denied permission is far harder to // recover than one that was never asked for. - val ask = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { allowed -> - granted = allowed - if (allowed) model.startRecording() - } - val seconds = chat.recordingSeconds - if (seconds == null) { - // ⚠️ In the composer's grammar, not a sentence-length orange link - // hanging under it. It was the widest text on the screen and the least - // used control on it. - KitChip( - text = "Voice note", - icon = KitIcon.Mic, - selected = false, - onClick = { - if (granted) model.startRecording() - else ask.launch(Manifest.permission.RECORD_AUDIO) - }, - tag = "record-voice", - ) - } else { + // 🔴 Only the ACTIVE recording. Starting one is the mic icon in the + // composer row now; this is the timer and the two decisions, which the kit + // has no equivalent for and which genuinely need a line of their own. + val seconds = chat.recordingSeconds ?: return + run { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,