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:
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,
|
||||
|
||||
Reference in New Issue
Block a user