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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 18:30:07 -04:00
co-authored by Claude Opus 5
parent 94337cbc07
commit 7719ae062e
@@ -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,