WhatsApp (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports WhatsApp’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (WhatsApp’s mint-leaf outgoing bubble, single-green verb color, blue double-check read receipts, doodle wallpaper) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, a translucentSurfaceinstead of.regularMaterialblur,sp/dpinstead ofpt. The app is light-first with a blue-black dark mode; both are wired below.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for contact avatars and media. No color extraction — WhatsApp’s palette is fixed brand chrome.
1. Color Tokens
// ui/theme/WhatsAppColors.kt
import androidx.compose.ui.graphics.Color
object WhatsAppColors {
// Brand
val Green = Color(0xFF25D366)
val GreenPressed = Color(0xFF1EBE5D)
val Teal = Color(0xFF075E54)
val MidTeal = Color(0xFF128C7E)
val DarkTeal = Color(0xFF054D44)
// Chat bubbles
val OutgoingLight = Color(0xFFD9FDD3) // the iconic mint-leaf
val OutgoingDark = Color(0xFF005C4B)
val IncomingLight = Color(0xFFFFFFFF)
val IncomingDark = Color(0xFF1F2C34)
val ReplyStrip = Color(0xFF06CF9C)
// Canvas & surfaces
val WallpaperLight = Color(0xFFECE5DD) // cream doodle canvas
val WallpaperDark = Color(0xFF0B141A)
val CanvasLight = Color(0xFFFFFFFF)
val CanvasDark = Color(0xFF111B21) // blue-black, NOT pure black
val Surface1Light = Color(0xFFF7F8FA)
val Surface1Dark = Color(0xFF202C33)
val Surface2Dark = Color(0xFF2A3942)
val DividerLight = Color(0xFFE9EDEF)
val DividerDark = Color(0xFF222D34)
val NavTintLight = Color(0xFFF0F2F5) // chat-screen top nav / search pill
// Text
val TextPrimary = Color(0xFF111B21)
val TextSecondary = Color(0xFF667781)
val TextTertiary = Color(0xFF8696A0)
val TextPrimaryDark = Color(0xFFE9EDEF)
val TextSecondaryDark = Color(0xFF8696A0)
// Semantic
val ReadBlue = Color(0xFF53BDEB) // double blue ticks — most load-bearing color
val SentGray = Color(0xFF8696A0)
val ErrorRed = Color(0xFFF15C6D)
val EncBanner = Color(0xFFFFF5C4) // end-to-end encryption pill (light)
}
WhatsApp is light-first with a true dark mode. Provide both schemes; brand greens keep the same hex in either. Active tab tint is brand green (unlike iOS Messages), so primary carries it.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
private val WhatsAppLight = lightColorScheme(
primary = WhatsAppColors.Green,
onPrimary = Color.White,
background = WhatsAppColors.CanvasLight,
onBackground = WhatsAppColors.TextPrimary,
surface = WhatsAppColors.CanvasLight,
onSurface = WhatsAppColors.TextPrimary,
surfaceVariant = WhatsAppColors.Surface1Light,
outline = WhatsAppColors.DividerLight,
error = WhatsAppColors.ErrorRed,
)
private val WhatsAppDark = darkColorScheme(
primary = WhatsAppColors.Green,
onPrimary = Color.White,
background = WhatsAppColors.CanvasDark,
onBackground = WhatsAppColors.TextPrimaryDark,
surface = WhatsAppColors.Surface1Dark,
onSurface = WhatsAppColors.TextPrimaryDark,
surfaceVariant = WhatsAppColors.Surface2Dark,
outline = WhatsAppColors.DividerDark,
error = WhatsAppColors.ErrorRed,
)
@Composable
fun WhatsAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (darkTheme) WhatsAppDark else WhatsAppLight,
typography = WhatsAppTypography,
content = content,
)
2. Typography
WhatsApp ships no proprietary typeface — it defers entirely to the system font. On Android that is Roboto (FontFamily.Default), the closest analog to iOS’s SF Pro. Do not bundle a custom font; the whole point of WhatsApp’s type system is OS deference and full font-scale support.
// ui/theme/WhatsAppType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// WhatsApp uses the system face — Roboto on Android (SF Pro analog).
private val WASans = FontFamily.Default
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object WhatsAppText {
val LargeTitle = TextStyle(WASans, fontWeight = FontWeight.Bold, fontSize = 34.sp, lineHeight = 38.sp, letterSpacing = 0.37.sp)
val ScreenTitle = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.4).sp)
val ContactName = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
val MessagePreview = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val BubbleBody = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
val GroupSender = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = (-0.1).sp)
val TimestampList = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 14.sp)
val TimestampBubble = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 11.sp)
val SectionHeader = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = (-0.1).sp)
val SystemMessage = TextStyle(WASans, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp)
val TabLabel = TextStyle(WASans, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.4).sp)
val InputPlaceholder = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.3).sp)
val SettingsRow = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
val SettingsDetail = TextStyle(WASans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
}
// Map onto Material 3 slots so stock components inherit the ramp
val WhatsAppTypography = Typography(
headlineLarge = WhatsAppText.LargeTitle,
titleLarge = WhatsAppText.ScreenTitle,
titleMedium = WhatsAppText.ContactName,
bodyLarge = WhatsAppText.BubbleBody,
bodyMedium = WhatsAppText.MessagePreview,
labelSmall = WhatsAppText.TabLabel,
)
3. Signature Components
Send / Mic Morphing Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.unit.dp
@Composable
fun WASendButton(
hasText: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.92f else 1f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 600f),
label = "sendScale",
)
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.size(36.dp)
.scale(scale)
.clip(CircleShape)
.background(if (pressed) WhatsAppColors.GreenPressed else WhatsAppColors.Green)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS soft impact
onClick()
},
contentAlignment = Alignment.Center,
) {
// No SymbolEffect on Android — crossfade the glyph instead (see §6)
Icon(
imageVector = if (hasText) Icons.Filled.Send else Icons.Filled.Mic,
contentDescription = if (hasText) "Send message" else "Record voice message",
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
Chat Bubble (Outgoing — asymmetric tail + tick row)
Compose has no UnevenRoundedRectangle; use RoundedCornerShape with the bottom-end corner zeroed for the outgoing tail nub.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.text.style.TextAlign
enum class ReadState { Sent, Delivered, Read }
@Composable
fun WAOutgoingBubble(
text: String,
timestamp: String,
readState: ReadState,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth().padding(end = 8.dp)) {
Spacer(Modifier.weight(0.2f)) // 80% max bubble width
Column(
modifier = Modifier
.weight(0.8f, fill = false)
.clip(
RoundedCornerShape(
topStart = 12.dp, topEnd = 12.dp,
bottomStart = 12.dp, bottomEnd = 0.dp, // tail nub
)
)
.background(WhatsAppColors.OutgoingLight)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.End,
) {
Text(text, style = WhatsAppText.BubbleBody, color = WhatsAppColors.TextPrimary)
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
timestamp,
style = WhatsAppText.TimestampBubble,
color = WhatsAppColors.TextSecondary,
)
Spacer(Modifier.width(3.dp))
TickRow(readState)
}
}
}
}
@Composable
private fun TickRow(state: ReadState) {
val tint = if (state == ReadState.Read) WhatsAppColors.ReadBlue else WhatsAppColors.SentGray
val count = if (state == ReadState.Sent) 1 else 2
// Overlap the two checks by -4.dp so they read as one glyph
Row(horizontalArrangement = Arrangement.spacedBy((-4).dp)) {
repeat(count) {
Icon(
imageVector = Icons.Filled.Done,
contentDescription = null,
tint = tint,
modifier = Modifier.size(13.dp),
)
}
}
}
Chat List Row (with status ring)
import coil.compose.AsyncImage
import androidx.compose.foundation.border
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun WAChatListRow(
avatarUrl: String,
name: String,
preview: String,
timestamp: String,
unreadCount: Int,
hasStatusRing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier = modifier
.fillMaxWidth()
.height(72.dp)
.background(if (pressed) WhatsAppColors.Surface1Light else Color.Transparent)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(contentAlignment = Alignment.Center) {
if (hasStatusRing) {
Box(
Modifier
.size(54.dp)
.border(2.5.dp, WhatsAppColors.Green, CircleShape)
)
}
AsyncImage(
model = avatarUrl,
contentDescription = null,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(name, style = WhatsAppText.ContactName, color = WhatsAppColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(preview, style = WhatsAppText.MessagePreview, color = WhatsAppColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
timestamp,
style = WhatsAppText.TimestampList,
color = if (unreadCount > 0) WhatsAppColors.Green else WhatsAppColors.TextSecondary,
)
if (unreadCount > 0) {
Box(
Modifier
.clip(CircleShape)
.background(WhatsAppColors.Green)
.padding(horizontal = 7.dp, vertical = 2.dp),
) {
Text("$unreadCount", style = WhatsAppText.TimestampList, color = Color.White)
}
}
}
}
}
4. Voice Waveform Bubble + Blue Double-Check Read Receipts
WhatsApp’s two defining interactive systems. The voice-message waveform fills its bars green left-to-right synced to playback, with a soft haptic tick as playback crosses each amplitude peak. The read receipt crossfades the overlapping checkmarks gray → blue when the recipient opens the message.
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.delay
import kotlin.math.max
@Composable
fun WAVoiceWaveformBubble(
duration: String,
amplitudes: List<Float>, // normalized 0f..1f, ~48 samples
modifier: Modifier = Modifier,
) {
var isPlaying by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) } // 0f..1f played fraction
val haptics = LocalHapticFeedback.current
// Advance playback while playing; soft tick on amplitude peaks
LaunchedEffect(isPlaying) {
if (!isPlaying) return@LaunchedEffect
while (isPlaying && progress < 1f) {
delay(80)
val prevBar = (progress * amplitudes.size).toInt()
progress = (progress + 0.02f).coerceAtMost(1f)
val newBar = (progress * amplitudes.size).toInt()
if (newBar != prevBar && newBar < amplitudes.size && amplitudes[newBar] > 0.85f) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // subtle peak tick
}
}
if (progress >= 1f) isPlaying = false
}
Row(
modifier = modifier
.clip(
RoundedCornerShape(
topStart = 12.dp, topEnd = 12.dp,
bottomStart = 12.dp, bottomEnd = 0.dp,
)
)
.background(WhatsAppColors.OutgoingLight)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(WhatsAppColors.Green)
.clickable { isPlaying = !isPlaying },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play voice message",
tint = Color.White,
modifier = Modifier.size(16.dp),
)
}
Row(
modifier = Modifier.weight(1f).height(24.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
amplitudes.forEachIndexed { i, amp ->
val played = i.toFloat() / amplitudes.size <= progress
Box(
Modifier
.width(2.dp)
.height(max(4f, amp * 20f).dp)
.clip(CircleShape)
.background(if (played) WhatsAppColors.Green else WhatsAppColors.TextTertiary)
)
}
}
Text(duration, style = WhatsAppText.TimestampBubble, color = WhatsAppColors.TextTertiary)
}
}
/** Read receipt that crossfades gray → blue when [readState] flips to Read. */
@Composable
fun AnimatedTickRow(readState: ReadState) {
val targetTint = if (readState == ReadState.Read) WhatsAppColors.ReadBlue else WhatsAppColors.SentGray
val tint by androidx.compose.animation.animateColorAsState(
targetValue = targetTint,
animationSpec = tween(200, easing = LinearEasing),
label = "tickColor",
)
val count = if (readState == ReadState.Sent) 1 else 2
Row(horizontalArrangement = Arrangement.spacedBy((-4).dp)) {
repeat(count) {
Icon(Icons.Filled.Done, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
}
}
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. WhatsApp’s iOS tab bar is .regularMaterial blur over canvas; Android has no first-class live blur, so use a 92%-opaque canvas surface. Active tint is brand green — unlike iOS Messages, WhatsApp uses #25D366 as the selected tab tint. The “new chat” FAB is not in the tab bar — it lives as a nav-bar trailing action on the Chats screen.
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.DonutLarge
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@Composable
fun WABottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = WhatsAppColors.CanvasLight.copy(alpha = 0.92f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Updates" to Icons.Outlined.DonutLarge,
"Calls" to Icons.Filled.Phone,
"Communities" to Icons.Filled.Groups,
"Chats" to Icons.Filled.Chat,
"Settings" to Icons.Filled.Settings,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = WhatsAppText.TabLabel) },
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = WhatsAppColors.Green, // brand-green active
selectedTextColor = WhatsAppColors.Green,
unselectedIconColor = WhatsAppColors.TextTertiary,
unselectedTextColor = WhatsAppColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — WhatsApp has none
),
)
}
}
}
The compose bar sits in the Scaffold bottomBar slot on the chat screen (not the tab bar): a Surface(color = Surface1Light) row holding a pill TextField plus the WASendButton.
6. Motion
| Moment | Compose recipe |
|---|---|
| Send button press | animateFloatAsState 1 → 0.92 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress |
| Send/mic glyph morph | Crossfade(targetState = hasText) (no iOS symbolEffect) |
| Read receipt → blue | animateColorAsState gray → #53BDEB over 200ms tween |
| Voice playback fill | bars recolor as progress advances; HapticFeedbackType.TextHandleMove on amplitude peaks |
| Mic record hold | outer ring scale = 1f + amplitude * 0.2f driven by decibel state |
| Swipe-to-reply | Modifier.pointerInput horizontal drag; HapticFeedbackType.TextHandleMove at threshold |
| Reaction tap | Animatable keyframes 1 → 1.2 → 1; HapticFeedbackType.LongPress |
// Send/mic glyph morph — Android's analog to iOS .contentTransition(.symbolEffect(.replace))
import androidx.compose.animation.Crossfade
@Composable
fun MorphingSendGlyph(hasText: Boolean) {
Crossfade(targetState = hasText, label = "sendMorph") { showSend ->
Icon(
imageVector = if (showSend) Icons.Filled.Send else Icons.Filled.Mic,
contentDescription = if (showSend) "Send message" else "Record voice message",
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
Haptics: prefer LocalHapticFeedback. For the soft per-peak tick during voice playback, HapticFeedbackType.TextHandleMove is the lightest stock effect; for richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, 40) to approximate iOS’s .soft impact.
7. Icons
WhatsApp uses near-stock iconography; the closest first-party set is androidx.compose.material:material-icons-extended. The Updates “circle.dashed” glyph has no exact Material twin — ship it (and the WhatsApp logomark) as a vector drawable loaded via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Send | paperplane.fill |
Icons.Filled.Send |
| Microphone | mic.fill |
Icons.Filled.Mic |
| Attach | paperclip |
Icons.Filled.AttachFile |
| Camera | camera.fill |
Icons.Filled.PhotoCamera |
| Emoji | face.smiling |
Icons.Filled.EmojiEmotions |
| Read ticks | checkmark |
Icons.Filled.Done |
| Phone (answer) | phone.fill |
Icons.Filled.Call |
| Phone (decline) | phone.down.fill |
Icons.Filled.CallEnd |
| Video | video.fill |
Icons.Filled.Videocam |
| Lock (E2E) | lock.fill |
Icons.Filled.Lock |
| Chats tab | message.fill |
Icons.Filled.Chat |
| Calls tab | phone.fill |
Icons.Filled.Phone |
| Communities tab | person.3.fill |
Icons.Filled.Groups |
| Updates tab | circle.dashed |
Icons.Outlined.DonutLarge (or vector drawable) |
| Settings tab | gearshape.fill |
Icons.Filled.Settings |
| New chat | square.and.pencil |
Icons.Filled.Edit |
| Search | magnifyingglass |
Icons.Filled.Search |
| Ellipsis | ellipsis |
Icons.Filled.MoreVert |
| Play / Pause (voice) | play.fill / pause.fill |
Icons.Filled.PlayArrow / Icons.Filled.Pause |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. The chat wallpaper should full-bleed behind the system bars; applyScaffoldinsets so the compose bar clears the gesture nav and the bottom tab bar clears the home indicator. - Font scaling:
sphonors the user’s font scale automatically — keep it on bubble body, contact names, and previews so WhatsApp’s Dynamic-Type-first promise carries over. Pin inline timestamps (11spin-bubble,12splist) and the10sptab labels by deriving them from a fixed-fontScaleLocalDensityso the inline time/tick row never breaks layout. - TalkBack: label the send button dynamically (
"Send message"vs"Record voice message"); merge bubble text + timestamp + read-state into one node withModifier.semantics(mergeDescendants = true)and announce read state as content ("Read"/"Delivered"). Do not hide the end-to-end encryption banner — expose it as static text. - Touch targets: Material guidance is 48.dp minimum. The 36.dp send/mic and play circles need a 48.dp hit area via surrounding padding even though the glyph is small; chat-list rows at 72.dp are well clear.
- RTL: the asymmetric bubble tail must flip for Arabic/Hebrew — use
RoundedCornerShapewithtopStart/bottomEnd(logical corners) rather than absolute left/right so Compose mirrors it automatically. Test with a forced RTL locale. - Contrast:
#D9FDD3outgoing bubble +#111B21text far exceeds WCAG AA. Validate the dark-mode#005C4Bbubble +#E9EDEFtext (passes AA) and the#8696A0secondary text on#FFFFFF— the latter is borderline at the 11–12sp timestamp size; bump toward#667781if your build targets strict compliance. - Dynamic color: do not enable Material You
dynamicColorScheme()— WhatsApp’s brand requires the fixed green verb color and the mint-leaf bubble regardless of wallpaper. Keep the hand-built light/dark schemes above.