Telegram (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Telegram’s visual language to Android with Jetpack Compose (Material 3): a themeable color system, 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. Telegram’s whole identity is that the accent is a single user-chosen variable and the UI rhythm (bubble shape, type scale, gesture language) holds steady no matter what color is poured in. This file keeps that identity — a themeable accent via
CompositionLocal, the 17dp bubble with a 6dp notch tail, the floating voice pill, Lottie animated emoji — while making everything idiomatic Android:NavigationBarinstead of a UITabBar, a translucentSurfaceinstead of.regularMaterialblur,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for avatars, and com.airbnb.android:lottie-compose6.4+ for animated emoji & sticker playback. No color extraction — Telegram’s accent comes from the theme, not media, so androidx.palette is not required.
1. Color Tokens
Telegram’s entire color system is themeable — the accent flows through as a single variable. Model it as a TelegramTheme data class hoisted into a CompositionLocal so every consuming composable re-tints live when the user picks a new accent.
// ui/theme/TelegramColors.kt
import androidx.compose.ui.graphics.Color
object TelegramColors {
// Default Accent (user-themeable)
val Accent = Color(0xFF0088CC)
val AccentLight = Color(0xFF40A7E3)
val AccentPressed = Color(0xFF0071B0)
// Bubble (default blue theme)
val BubbleOutgoing = Color(0xFF2B86FD)
val BubbleOutgoingTop = Color(0xFF2B86FD) // gradient top
val BubbleOutgoingBot = Color(0xFF61B3FF) // gradient bottom
val BubbleIncomingLight = Color(0xFFFFFFFF)
val BubbleIncomingDark = Color(0xFF2A2A2A)
val SecretOutgoing = Color(0xFF4EA53C)
// Canvas
val CanvasLight = Color(0xFFFFFFFF)
val CanvasDark = Color(0xFF212121)
val CanvasOLED = Color(0xFF000000)
val ChatBGBlue = Color(0xFFDBE7F4) // default soft-blue wallpaper
val Surface1Light = Color(0xFFF7F7F7)
val Surface2Light = Color(0xFFEFEFF4)
val Surface1Dark = Color(0xFF1C1C1C)
val Surface2Dark = Color(0xFF2C2C2E)
val DividerLight = Color(0xFFC7C7CC)
val DividerDark = Color(0xFF383838)
val OLEDSurface = Color(0xFF0A0A0A)
// Text
val TextPrimary = Color(0xFF000000)
val TextSecondary = Color(0xFF707579)
val TextTertiary = Color(0xFFA0A6AD)
val TextPrimaryDark = Color(0xFFFFFFFF)
val TextSecondaryDark = Color(0xFF8D8E93)
// Semantic
val ReadBlue = Color(0xFF0088CC)
val SentGray = Color(0xFFB1B1B1)
val ErrorRed = Color(0xFFE53935)
val Destructive = Color(0xFFE35561)
val OnlineGreen = Color(0xFF4DD364)
val PremiumA = Color(0xFFAE6FFD)
val PremiumB = Color(0xFFCE6BFF)
}
// Sender color palette (group chats) — assign by stable sender id
object TgSenderColors {
private val palette = listOf(
Color(0xFFFC5C51), Color(0xFFFA790F), Color(0xFF895DD5), Color(0xFF0FB297),
Color(0xFF00C1A6), Color(0xFF3CA5EC), Color(0xFFFF5274),
)
fun forSender(id: Int): Color = palette[(id % palette.size + palette.size) % palette.size]
}
Themeable accent via CompositionLocal
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
data class TelegramTheme(
val accent: Color = TelegramColors.Accent,
val outgoingBubble: Color = TelegramColors.BubbleOutgoing,
val useGradientBubbles: Boolean = false,
val isDark: Boolean = false,
val isOLED: Boolean = false,
) {
val canvas: Color get() = when {
isOLED -> TelegramColors.CanvasOLED
isDark -> TelegramColors.CanvasDark
else -> TelegramColors.CanvasLight
}
val textPrimary: Color get() = if (isDark) TelegramColors.TextPrimaryDark else TelegramColors.TextPrimary
val textSecondary: Color get() = if (isDark) TelegramColors.TextSecondaryDark else TelegramColors.TextSecondary
val incomingBubble: Color get() = if (isDark) TelegramColors.BubbleIncomingDark else TelegramColors.BubbleIncomingLight
}
val LocalTelegramTheme = staticCompositionLocalOf { TelegramTheme() }
@Composable
fun TelegramAppTheme(
theme: TelegramTheme = TelegramTheme(),
content: @Composable () -> Unit,
) {
// Derive a Material 3 scheme from the live accent so ripples/dividers inherit it
val scheme = if (theme.isDark) {
darkColorScheme(
primary = theme.accent,
background = theme.canvas,
surface = if (theme.isOLED) TelegramColors.OLEDSurface else TelegramColors.Surface1Dark,
onSurface = TelegramColors.TextPrimaryDark,
outline = TelegramColors.DividerDark,
error = TelegramColors.ErrorRed,
)
} else {
lightColorScheme(
primary = theme.accent,
background = theme.canvas,
surface = TelegramColors.Surface1Light,
onSurface = TelegramColors.TextPrimary,
outline = TelegramColors.DividerLight,
error = TelegramColors.ErrorRed,
)
}
CompositionLocalProvider(LocalTelegramTheme provides theme) {
MaterialTheme(colorScheme = scheme, typography = TelegramTypography, content = content)
}
}
Change theme.accent (e.g. to TgSenderColors-style green, rose, or purple) at the app root and every consuming view re-tints live — that is the Telegram product.
2. Typography
Telegram ships no proprietary font on mobile — it uses the platform UI font. On iOS that’s SF Pro; the Android system equivalent is Roboto, so use FontFamily.Default and FontFamily.Monospace for code blocks. Sizes follow Apple HIG / Material parity at 1:1 pt→sp.
// ui/theme/TelegramType.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val Sans = FontFamily.Default // Roboto on Android (Telegram uses the system UI font)
private val Mono = FontFamily.Monospace // code blocks / pre
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, weights preserved)
object TelegramText {
val LargeTitle = TextStyle(Sans, fontWeight = FontWeight.Bold, fontSize = 34.sp, lineHeight = 38.sp, letterSpacing = 0.37.sp)
val ScreenTitle = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.4).sp)
val ContactName = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
val BubbleBody = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
val GroupSender = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 17.sp, letterSpacing = (-0.2).sp)
val MessagePreview = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val TimestampList = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
val TimestampBubble = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 11.sp)
val SectionHeader = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
val Body = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = (-0.4).sp)
val Callout = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.3).sp)
val Footnote = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = (-0.1).sp)
val Caption = TextStyle(Sans, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val TabLabel = TextStyle(Sans, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.4).sp)
val Code = TextStyle(Mono, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Blockquote = TextStyle(Sans, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
}
// Map onto Material 3 slots so stock components inherit the scale
val TelegramTypography = Typography(
headlineLarge = TelegramText.LargeTitle,
titleMedium = TelegramText.ContactName,
bodyLarge = TelegramText.BubbleBody,
bodyMedium = TelegramText.MessagePreview,
labelSmall = TelegramText.TabLabel,
)
3. Signature Components
Compose Bar (with morphing send)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
@Composable
fun TgComposeBar(
onSend: (String) -> Unit,
onSendSilent: (String) -> Unit,
onSchedule: () -> Unit,
modifier: Modifier = Modifier,
) {
val theme = LocalTelegramTheme.current
var text by remember { mutableStateOf("") }
var menuOpen by remember { mutableStateOf(false) }
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.background(if (theme.isDark) TelegramColors.Surface1Dark else TelegramColors.CanvasLight)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Filled.AttachFile, "Attach",
tint = theme.textSecondary,
modifier = Modifier.size(22.dp).rotate(45f),
)
BasicTextField(
value = text,
onValueChange = { text = it },
textStyle = TelegramText.Callout.copy(color = theme.textPrimary),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) Text("Message", style = TelegramText.Callout, color = TelegramColors.TextTertiary)
inner()
},
)
Icon(Icons.Filled.EmojiEmotions, "Emoji", tint = theme.textSecondary, modifier = Modifier.size(22.dp))
if (text.isEmpty()) {
Icon(Icons.Filled.Mic, "Voice message", tint = theme.accent, modifier = Modifier.size(22.dp))
} else {
Box {
Icon(
Icons.Filled.Send, "Send",
tint = theme.accent,
modifier = Modifier
.size(22.dp)
.combinedClickableSend(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // soft impact on send
onSend(text); text = ""
},
onLongClick = { menuOpen = true },
),
)
DropdownMenu(menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Send Without Sound") },
leadingIcon = { Icon(Icons.Filled.NotificationsOff, null) },
onClick = { menuOpen = false; onSendSilent(text); text = "" },
)
DropdownMenuItem(
text = { Text("Schedule Message") },
leadingIcon = { Icon(Icons.Filled.Schedule, null) },
onClick = { menuOpen = false; onSchedule() },
)
DropdownMenuItem(
text = { Text("Send When Online") },
leadingIcon = { Icon(Icons.Filled.Person, null) },
onClick = { menuOpen = false; onSend(text); text = "" },
)
}
}
}
}
}
// long-press the send button = the signature silent/scheduled-send gesture
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@Composable
private fun Modifier.combinedClickableSend(onClick: () -> Unit, onLongClick: () -> Unit): Modifier =
this.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
onLongClick = onLongClick,
)
Outgoing Bubble (themeable solid + gradient variant, 6dp notch tail)
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@Composable
fun TgOutgoingBubble(
text: String,
timestamp: String,
isRead: Boolean,
modifier: Modifier = Modifier,
) {
val theme = LocalTelegramTheme.current
// 17dp all corners EXCEPT bottom-trailing = 6dp (subtle notch, not a full point)
val shape = RoundedCornerShape(topStart = 17.dp, topEnd = 17.dp, bottomStart = 17.dp, bottomEnd = 6.dp)
Row(modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) {
Spacer(Modifier.weight(0.2f))
Column(
modifier = Modifier
.clip(shape)
.then(
if (theme.useGradientBubbles)
Modifier.background(Brush.verticalGradient(listOf(TelegramColors.BubbleOutgoingTop, TelegramColors.BubbleOutgoingBot)))
else Modifier.background(theme.outgoingBubble)
)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.End,
) {
Text(text, style = TelegramText.BubbleBody, color = TelegramColors.TextPrimaryDark)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text(timestamp, style = TelegramText.TimestampBubble, color = TelegramColors.TextPrimaryDark.copy(alpha = 0.8f))
Icon(
if (isRead) Icons.Filled.DoneAll else Icons.Filled.Done,
contentDescription = if (isRead) "Read" else "Sent",
tint = TelegramColors.TextPrimaryDark.copy(alpha = 0.9f),
modifier = Modifier.size(12.dp),
)
}
}
}
}
Incoming Bubble (tail notch mirrored to bottom-leading)
import androidx.compose.foundation.border
@Composable
fun TgIncomingBubble(
text: String,
senderName: String?,
senderId: Int?,
modifier: Modifier = Modifier,
) {
val theme = LocalTelegramTheme.current
val shape = RoundedCornerShape(topStart = 17.dp, topEnd = 17.dp, bottomStart = 6.dp, bottomEnd = 17.dp)
Row(modifier.fillMaxWidth().padding(start = 8.dp)) {
Column(
modifier = Modifier
.clip(shape)
.background(theme.incomingBubble)
.then(if (theme.isDark) Modifier else Modifier.border(1.dp, Color(0xFFE5E5E5), shape))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (senderName != null && senderId != null) {
Text(senderName, style = TelegramText.GroupSender, color = TgSenderColors.forSender(senderId))
}
Text(text, style = TelegramText.BubbleBody, color = theme.textPrimary)
}
Spacer(Modifier.weight(0.2f))
}
}
Floating Voice Mini-Player
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.shadow
@Composable
fun TgVoiceMiniPlayer(
title: String,
progress: Float,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val theme = LocalTelegramTheme.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(40.dp)
.shadow(16.dp, RoundedCornerShape(22.dp), spotColor = Color.Black.copy(alpha = 0.16f))
.clip(RoundedCornerShape(22.dp))
.background(if (theme.isDark) TelegramColors.Surface1Dark else TelegramColors.CanvasLight)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Pause, "Pause", tint = theme.accent, modifier = Modifier.size(14.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = TelegramText.Footnote.copy(fontWeight = FontWeight.SemiBold), color = theme.textPrimary, maxLines = 1)
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.clip(CircleShape)
.background(TelegramColors.DividerLight),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(2.dp)
.clip(CircleShape)
.background(theme.accent),
)
}
}
Icon(
Icons.Filled.Close, "Close",
tint = theme.textSecondary,
modifier = Modifier.size(13.dp).clickableNoRipple(onClose),
)
}
}
import androidx.compose.foundation.clickable
@Composable
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
this.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
This pill should be rendered just below the top app bar in the chat Scaffold and survive chat navigation (hoist its state into a shared ViewModel) — persistence across chats is the Telegram differentiator.
Swipe-from-Edge to Reply
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.roundToInt
@Composable
fun TgSwipeToReply(
onReply: () -> Unit,
content: @Composable () -> Unit,
) {
val theme = LocalTelegramTheme.current
val haptics = LocalHapticFeedback.current
val density = LocalDensity.current
val thresholdPx = with(density) { 60.dp.toPx() }
var offset by remember { mutableFloatStateOf(0f) }
var ticked by remember { mutableStateOf(false) }
val animated by animateFloatAsState(offset, spring(dampingRatio = 0.8f, stiffness = 600f), label = "replyOffset")
Box(
Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (offset >= thresholdPx) onReply()
offset = 0f; ticked = false
},
) { _, dragAmount ->
offset = (offset + dragAmount).coerceIn(0f, thresholdPx + 20f)
if (offset >= thresholdPx && !ticked) {
ticked = true
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // soft tick at threshold
}
}
},
) {
Icon(
Icons.Filled.Reply, null,
tint = theme.accent,
modifier = Modifier
.align(Alignment.CenterStart)
.offset { IntOffset((-32 + animated * 0.3f).roundToInt(), 0) }
.graphicsAlpha((animated / thresholdPx).coerceIn(0f, 1f)),
)
Box(Modifier.offset { IntOffset(animated.roundToInt(), 0) }) { content() }
}
}
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.IntOffset
private fun Modifier.graphicsAlpha(a: Float) = this.alpha(a)
Animated Emoji / Sticker (Lottie)
Telegram’s crown jewel: a single-emoji message renders large and plays a vector animation once on arrival. iOS uses lottie-ios; Compose uses lottie-compose.
import com.airbnb.lottie.compose.*
@Composable
fun TgAnimatedEmoji(
assetName: String, // e.g. "emoji/heart.json" in assets/
sizeDp: Int = 72, // single emoji renders at 72dp
modifier: Modifier = Modifier,
) {
val composition by rememberLottieComposition(LottieCompositionSpec.Asset(assetName))
// play once on arrival; bump iterations to loop "interactive" stickers
val progress by animateLottieCompositionAsState(
composition,
iterations = 1,
speed = 1f,
)
LottieAnimation(
composition = composition,
progress = { progress },
modifier = modifier.size(sizeDp.dp),
)
}
For “interactive” emoji (heart/rocket) that play full-screen across both chats, render a LottieAnimation at ~1024dp in a Box overlay (Popup or top-level ZStack) and auto-dismiss on progress == 1f.
4. Chat List Row
@Composable
fun TgChatListRow(
avatarUrl: String,
name: String,
preview: String,
timestamp: String,
unreadCount: Int,
isPinned: Boolean,
isMuted: Boolean,
modifier: Modifier = Modifier,
) {
val theme = LocalTelegramTheme.current
Row(
modifier = modifier
.fillMaxWidth()
.height(76.dp)
.background(if (isPinned) TelegramColors.Surface1Light else theme.canvas)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
coil.compose.AsyncImage(
model = avatarUrl, contentDescription = null,
modifier = Modifier.size(54.dp).clip(CircleShape),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(name, style = TelegramText.ContactName, color = theme.textPrimary, maxLines = 1)
if (isMuted) Icon(Icons.Filled.NotificationsOff, null, tint = theme.textSecondary, modifier = Modifier.size(11.dp))
}
Text(preview, style = TelegramText.MessagePreview, color = theme.textSecondary, maxLines = 1)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(timestamp, style = TelegramText.TimestampList, color = theme.textSecondary)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (isPinned) Icon(Icons.Filled.PushPin, null, tint = theme.textSecondary, modifier = Modifier.size(11.dp))
if (unreadCount > 0) {
Box(
Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (isMuted) TelegramColors.TextTertiary else theme.accent)
.padding(horizontal = 7.dp, vertical = 2.dp),
) {
Text("$unreadCount", style = TelegramText.TimestampList.copy(fontWeight = FontWeight.SemiBold), color = Color.White)
}
}
}
}
}
}
5. Navigation (Tab Bar)
Telegram’s iOS tab bar is a .regularMaterial blur over the canvas with custom outlined glyphs (not SF Symbols everywhere). Android has no first-class live blur — use a translucent NavigationBar (containerColor at ~92% alpha) and ship the brand-specific glyphs (the Telegram plane, the channel megaphone) as vector drawables. The active tint reads from the live themed accent.
@Composable
fun TgBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val theme = LocalTelegramTheme.current
NavigationBar(
containerColor = theme.canvas.copy(alpha = 0.92f), // translucent — no live blur on Android
tonalElevation = 0.dp,
) {
val items = listOf(
"Contacts" to Icons.Filled.Contacts,
"Calls" to Icons.Filled.Call,
"Chats" to Icons.Filled.ChatBubble, // swap for custom Telegram bubble drawable
"Settings" to Icons.Filled.Settings,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(28.dp)) },
label = { Text(label, style = TelegramText.TabLabel) },
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = theme.accent,
selectedTextColor = theme.accent,
unselectedIconColor = TelegramColors.TextSecondaryDark,
unselectedTextColor = TelegramColors.TextSecondaryDark,
indicatorColor = Color.Transparent,
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Bubble arrival | enter slideInVertically + slight overshoot spring(dampingRatio = 0.6f) |
| Animated emoji | Lottie iterations = 1, plays once on arrival; interactive emoji full-screen overlay |
| Swipe-to-reply | detectHorizontalDragGestures, soft haptic tick at 60dp threshold |
| Silent-send long-press | combinedClickable(onLongClick) → DropdownMenu (Android long-press ~500ms) |
| Voice mini-player entry | AnimatedVisibility + slideInVertically(initialOffsetY = { -it }) over ~200ms |
| Reactions palette | scaleIn() + fadeIn() from bubble center, spring, ~300ms |
| Context menu open | bubble scale 1.0 → 1.05, translucent scrim, menu fans below |
// Bubble arrival — slide up + overshoot
@Composable
fun BubbleAppear(visible: Boolean, content: @Composable () -> Unit) {
androidx.compose.animation.AnimatedVisibility(
visible = visible,
enter = androidx.compose.animation.slideInVertically(
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
initialOffsetY = { it / 3 },
) + androidx.compose.animation.fadeIn(),
) { content() }
}
Haptics: prefer LocalHapticFeedback. For a true soft impact on send (matching iOS .impact(flexibility: .soft)), use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, 80) — light and brief, like Telegram’s send tick.
7. Icons
Telegram uses a mix of system glyphs and a proprietary outlined set. Use Material Icons (material-icons-extended) where semantically equivalent; ship brand-specific glyphs (the Telegram plane, channel megaphone, sticker face, premium gradient star) as vector drawables loaded via ImageVector.vectorResource(R.drawable.…).
| Component | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Send | paperplane.fill |
Icons.Filled.Send (or custom Telegram plane drawable) |
| Mic | mic.fill |
Icons.Filled.Mic |
| Attach | paperclip (rotated 45°) |
Icons.Filled.AttachFile (.rotate(45f)) |
| Emoji | face.smiling |
Icons.Filled.EmojiEmotions |
| Sticker | custom outlined sticker | vector drawable |
| Read ticks | checkmark / checkmark.circle.fill |
Icons.Filled.Done / Icons.Filled.DoneAll |
| Phone | phone.fill |
Icons.Filled.Call |
| Video | video.fill |
Icons.Filled.Videocam |
| Lock (secret chat) | lock.fill |
Icons.Filled.Lock |
| Pinned | pin.fill |
Icons.Filled.PushPin |
| Muted | speaker.slash.fill |
Icons.Filled.NotificationsOff |
| Chats tab | bubble.left.and.bubble.right.fill |
Icons.Filled.ChatBubble (or custom) |
| Contacts tab | person.2.fill |
Icons.Filled.Contacts |
| Calls tab | phone.fill |
Icons.Filled.Call |
| Settings tab | gearshape.fill |
Icons.Filled.Settings |
| Channel broadcast | custom megaphone | vector drawable |
| Premium star | custom gradient star | vector drawable |
| Reply | arrowshape.turn.up.left.fill |
Icons.Filled.Reply |
| Schedule | calendar |
Icons.Filled.Schedule |
| Silent | speaker.slash |
Icons.Filled.NotificationsOff |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; Lottie +
RoundedCornerShapeper-corner + motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. System-bar icon contrast must follow the live theme (dark icons on the white canvas, light icons on#212121/OLED). ApplyScaffoldinsets so the compose bar hugs the keyboard/gesture inset; render the voice mini-player just below the top app bar inside the insets. - Themeable accent: the entire system reads
LocalTelegramTheme.current.accent. Provide a newTelegramThemeat the app root and every consuming composable recomposes and re-tints live — this is the core product behavior, so never hardcode a blue. - OLED mode: expose
isOLEDas a stored user preference distinct from system dark; when true the canvas becomes pure#000000and surfaces lift to#0A0A0A. - Font scaling:
sphonors the user’s font scale — scale bubble body, contact names, previews. Pin the 11sp in-bubble timestamp and 10sp tab labels (clamp viaCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f))). - TalkBack: merge bubble text + timestamp + read state into one node —
Modifier.semantics(mergeDescendants = true) { contentDescription = "$text, $timestamp, ${if (isRead) "read" else "sent"}" }. The swipe-to-reply gesture and decorative tail notch should beModifier.clearAndSetSemantics {}. - RTL:
RoundedCornerShape(topStart=…, bottomStart=…)auto-mirrors underLayoutDirection.Rtl, so the bubble tail notch flips correctly for Arabic/Persian/Hebrew with no extra code. - Reduce motion: detect
Settings.Global.ANIMATOR_DURATION_SCALE == 0f(or a stored pref) — render Lottie emoji as a static first frame (progress = { 0f }) and skip the bubble-arrival overshoot. - Inline Markdown: parse
**bold**,__italic__,||spoiler||,`code`into anAnnotatedStringwithSpanStyles; render spoiler as a blurred/drawWithContent-masked span that clears on tap. - Dynamic color: Telegram already is a user-theming system. Do not layer Material You
dynamicColorScheme()on top — the accent must come from the user’s explicit Telegram theme choice, not the wallpaper, or two competing theming systems fight.