Draw icons instead of typing them

Every icon in the Android app was a text character or an emoji — +, →, ‹, ⋯,
☎️, 🎤, ⊞ — and material-icons-extended had been a dependency the whole time
with zero Icon() calls against it. Reported three times as bare/text-links;
each time I adjusted spacing and type, because the structure genuinely did
match the kit and the code read correctly.

Adds KitIcons.kt as the vocabulary and migrates the tab bar, bar actions, row
overflow, composer, call controls, contact rail and alert glyphs. The tab bar
loses its text labels to match the kit's 'Bottom Bar/Without Label' artboard
and keeps them as accessibility names.

Also fixes the iOS composer sitting under the tab pill: HidesPillTabBar was a
PreferenceKey, and preferences do not travel out of a NavigationStack
destination, so the chat screen's request never reached the bar watching for
it. Now an observable with balanced retain/release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 17:23:47 -04:00
co-authored by Claude Opus 5
parent c9de1f26b8
commit 682d691a04
8 changed files with 319 additions and 112 deletions
@@ -2,6 +2,7 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,7 +29,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.clickable
/**
* A call, as a screen.
@@ -135,7 +136,7 @@ fun CallScreenBody(
/** One control on the call screen. */
data class CallAction(
val id: String,
val symbol: String,
val symbol: androidx.compose.ui.graphics.vector.ImageVector,
val label: String,
val isPrimary: Boolean = false,
val tint: Color? = null,
@@ -156,10 +157,11 @@ private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp
.testTag("call-${action.id}"),
contentAlignment = Alignment.Center,
) {
Text(
Icon(
action.symbol,
fontSize = (diameter.value * 0.36f).sp,
color = action.iconColor ?: MaterialTheme.colorScheme.onSurface,
contentDescription = null,
tint = action.iconColor ?: MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(diameter * 0.42f),
)
}
Spacer(Modifier.height(6.dp))
@@ -191,25 +193,25 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
val actions = when {
call.state == CallManager.State.RINGING && call.incoming -> listOf(
CallAction("decline", "✕", "Decline",
CallAction("decline", KitIcon.CallEnd, "Decline",
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::declineCall),
CallAction("accept", "☎", "Answer", isPrimary = true,
CallAction("accept", KitIcon.Call, "Answer", isPrimary = true,
tint = Palette.Teal, iconColor = Color.White,
run = model::acceptCall),
)
call.state == CallManager.State.ACTIVE -> listOf(
CallAction("mute", if (call.muted) "🔇" else "🎤",
CallAction("mute", if (call.muted) KitIcon.MicOff else KitIcon.Mic,
if (call.muted) "Unmute" else "Mute",
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
iconColor = if (call.muted) Color.White else null,
run = { model.setCallMuted(!call.muted) }),
CallAction("end", "✕", "End", isPrimary = true,
CallAction("end", KitIcon.CallEnd, "End", isPrimary = true,
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::hangUp),
)
else -> listOf(
CallAction("end", "✕", "Cancel", isPrimary = true,
CallAction("end", KitIcon.CallEnd, "Cancel", isPrimary = true,
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::hangUp),
)
@@ -1,75 +1,76 @@
package io.flamenet.messenger.app
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Surface
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyRow
import io.flamenet.e2e.Vault
import androidx.compose.ui.platform.testTag
import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.SafetyNumber
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.Vault
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import androidx.compose.foundation.layout.heightIn
import kotlinx.coroutines.launch
/**
* The people you talk to.
@@ -273,7 +274,7 @@ fun ColumnScope.BuddyList(
// Remove — with nowhere to explain what any of them did and
// nowhere to put a safety number.
IconAction(
symbol = "⋯",
icon = KitIcon.More,
label = "About $name",
tag = "contact-menu-${buddy.userId}",
onClick = { onOpenProfile(buddy.userId) },
@@ -528,7 +529,12 @@ fun ColumnScope.NotificationsScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Text("🔔", fontSize = 32.sp)
Icon(
KitIcon.Alerts,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing new", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"Contact requests and things that happened while you were away "
@@ -744,10 +750,11 @@ fun ColumnScope.AccountScreen(
tag = "delete-account",
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
) {
Text(
"›",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.error,
Icon(
KitIcon.RemoveContact,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(Kit.icon),
)
}
}
@@ -971,7 +978,7 @@ internal fun Avatar(
*/
@Composable
private fun IconAction(
symbol: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
tag: String,
danger: Boolean = false,
@@ -986,11 +993,12 @@ private fun IconAction(
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(
symbol,
fontSize = 20.sp,
color = if (danger) MaterialTheme.colorScheme.error
Icon(
icon,
contentDescription = null,
tint = if (danger) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
@@ -1004,17 +1012,22 @@ private fun shownGroupsExist(
@Composable
private fun AlertGlyph(kind: String) {
val symbol = when {
kind.contains("call", true) -> "☎"
kind.contains("contact", true) || kind.contains("request", true) -> "+"
kind.contains("group", true) -> "⊞"
kind.contains("story", true) -> "◎"
else -> "•"
kind.contains("call", true) -> KitIcon.Call
kind.contains("contact", true) || kind.contains("request", true) -> KitIcon.Add
kind.contains("group", true) -> KitIcon.NewGroup
kind.contains("story", true) -> KitIcon.Stories
else -> KitIcon.Dot
}
Box(
modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Text(symbol, fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
Icon(
symbol,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
@@ -1069,7 +1082,7 @@ fun ColumnScope.ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
IconAction(symbol = "‹", label = "Back", tag = "back", onClick = model::closeChat)
IconAction(icon = KitIcon.Back, label = "Back", tag = "back", onClick = model::closeChat)
Column(modifier = Modifier.weight(1f)) {
Text(
chat.title.ifBlank { "Conversation" },
@@ -1099,7 +1112,7 @@ fun ColumnScope.ChatScreen(
// work is worse than an absent one.
if (chat.peerId != null) {
IconAction(
symbol = "☎",
icon = KitIcon.Call,
label = "Call",
tag = "start-call",
onClick = { model.requestCall(chat.peerId) },
@@ -1107,7 +1120,7 @@ fun ColumnScope.ChatScreen(
}
chat.group?.let { group ->
IconAction(
symbol = "⏻",
icon = KitIcon.LeaveGroup,
label = "Leave group",
tag = "leave-group",
danger = true,
@@ -1163,7 +1176,7 @@ fun ColumnScope.ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.sm),
) {
IconAction(symbol = "+", label = "Attach a photo", tag = "attach",
IconAction(icon = KitIcon.Attach, label = "Attach a photo", tag = "attach",
onClick = { pickImage.launch("image/*") })
// ⚠️ `BasicTextField` inside our own pill rather than a Material text
@@ -1203,7 +1216,13 @@ fun ColumnScope.ChatScreen(
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(Kit.pillHeight).testTag("send"),
) { Text("→", fontSize = 20.sp) }
) {
Icon(
KitIcon.Send,
contentDescription = null,
modifier = Modifier.size(Kit.icon),
)
}
}
// Voice notes sit under the composer rather than inside it: recording
@@ -1249,7 +1268,8 @@ private fun ColumnScope.VoiceComposer(
// hanging under it. It was the widest text on the screen and the least
// used control on it.
KitChip(
text = "🎤 Voice note",
text = "Voice note",
icon = KitIcon.Mic,
selected = false,
onClick = {
if (granted) model.startRecording()
@@ -1593,7 +1613,12 @@ fun ColumnScope.StoryStrip(
.clickable { pickStory.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Text("+", fontSize = 22.sp, color = MaterialTheme.colorScheme.primary)
Icon(
KitIcon.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
Text("Post", fontSize = 12.sp, color = Palette.SecondaryText)
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -76,7 +77,12 @@ fun ColumnScope.ContactDetail(
.testTag("contact-back"),
contentAlignment = Alignment.Center,
) {
Text("‹", fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
Icon(
KitIcon.Back,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
Spacer(Modifier.weight(1f))
}
@@ -159,16 +165,16 @@ fun ColumnScope.ContactDetail(
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary
),
) {
RailButton("✉", "Message", "contact-message", enabled = !isBlocked) {
RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) {
model.openChat(buddy.userId, buddy.displayName); onClose()
}
RailDivider()
RailButton("☎", "Call", "contact-call", enabled = !isBlocked) {
RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) {
model.requestCall(buddy.userId)
}
RailDivider()
RailButton(
if (isBlocked) "◯" else "⊘",
if (isBlocked) KitIcon.PersonAdd else KitIcon.Block,
if (isBlocked) "Unblock" else "Block",
"contact-block",
) { model.setBlocked(buddy.userId, !isBlocked) }
@@ -214,7 +220,7 @@ private fun RailDivider() {
@Composable
private fun RailButton(
symbol: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
tag: String,
enabled: Boolean = true,
@@ -228,7 +234,12 @@ private fun RailButton(
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(symbol, fontSize = 18.sp, color = Color.White.copy(alpha = if (enabled) 1f else 0.5f))
Icon(
icon,
contentDescription = null,
tint = Color.White.copy(alpha = if (enabled) 1f else 0.5f),
modifier = Modifier.size(Kit.icon),
)
}
}
@@ -2,10 +2,14 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -103,6 +107,13 @@ fun KitChip(
onClick: () -> Unit,
modifier: Modifier = Modifier,
tag: String? = null,
/**
* ⚠️ Optional, and a real vector when present. The voice-note chip used to
* carry its picture as an emoji inside `text` — "🎤 Voice note" — which
* renders in whatever the system emoji font is, at the text baseline, in
* full colour. Beside a drawn monochrome icon it reads as a sticker.
*/
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
) {
Button(
onClick = onClick,
@@ -118,6 +129,10 @@ fun KitChip(
.height(36.dp)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
if (icon != null) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,94 @@
package io.flamenet.messenger.app
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBackIos
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.CallEnd
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.MicOff
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.ui.graphics.vector.ImageVector
/**
* The app's icon vocabulary, in one place.
*
* 🔴 **Why this file exists.** Every icon in this app used to be a text
* character or an emoji — `+`, `→`, `‹`, `⋯`, `☎️`, `🎤`, `⊞`. It was reported
* three times as "bare", "disorganized" and "a bunch of text links", and each
* time I adjusted spacing and type instead, because from the code the rows
* looked structurally right. They were: the kit's Messages screen and ours
* agree on the 24dp margin, the 48dp row on a 72dp pitch, the search pill and
* the story strip. What they did not agree on is that every piece of chrome in
* the kit is a drawn line icon and every piece of ours was a glyph in a text
* run. A `Text("→")` in a circle is not a send button, it is an arrow
* character, and no amount of layout work fixes that.
*
* ⚠️ `material-icons-extended` was already a dependency the whole time, and
* the app had **zero** `Icon(` calls and **zero** `material.icons` imports. The
* fix was never blocked on anything; it was blocked on looking at a render.
*
* Naming is by role, not by picture — `Kit.Send` rather than `Kit.PaperPlane` —
* so swapping the drawing later is one edit here and nothing at the call sites.
*/
object KitIcon {
// Chrome
val Back: ImageVector = Icons.AutoMirrored.Outlined.ArrowBackIos
val More: ImageVector = Icons.Outlined.MoreHoriz
val Close: ImageVector = Icons.Outlined.Close
val Add: ImageVector = Icons.Outlined.Add
val Search: ImageVector = Icons.Outlined.Search
// Tab bar. The active tab is the filled variant of the same shape, which
// is how the kit distinguishes it — not a different icon.
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
val ChatsActive: ImageVector = Icons.Filled.ChatBubble
val Stories: ImageVector = Icons.Outlined.PhotoCamera
val Alerts: ImageVector = Icons.Outlined.NotificationsNone
val Account: ImageVector = Icons.Outlined.PersonOutline
// Composer
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
val Camera: ImageVector = Icons.Outlined.PhotoCamera
val Send: ImageVector = Icons.AutoMirrored.Outlined.Send
val Mic: ImageVector = Icons.Outlined.Mic
// Calls
val Call: ImageVector = Icons.Outlined.Call
val Video: ImageVector = Icons.Outlined.Videocam
val CallEnd: ImageVector = Icons.Outlined.CallEnd
val MicOff: ImageVector = Icons.Outlined.MicOff
val Speaker: ImageVector = Icons.Outlined.VolumeUp
// Contact actions
val NewGroup: ImageVector = Icons.Outlined.GroupAdd
val Block: ImageVector = Icons.Outlined.Block
/** Unblocking: the same row, the opposite verb. */
val PersonAdd: ImageVector = Icons.Outlined.PersonAdd
val ClearChat: ImageVector = Icons.Outlined.DeleteOutline
val RemoveContact: ImageVector = Icons.Outlined.PersonRemove
val Verified: ImageVector = Icons.Outlined.Verified
val LeaveGroup: ImageVector = Icons.AutoMirrored.Outlined.Logout
/** Presence and unread dots, where a filled circle IS the icon. */
val Dot: ImageVector = Icons.Filled.Circle
}
@@ -3,36 +3,33 @@ package io.flamenet.messenger.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.draw.clip
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.background
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.width
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -44,18 +41,23 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.heightIn
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -441,36 +443,75 @@ private fun BottomNav(
horizontalArrangement = Arrangement.SpaceEvenly,
) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
"Contacts" + (phase.unread.size.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-contacts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, "Stories", "nav-stories")
KitIcon.Chats, KitIcon.ChatsActive, "Chats", phase.unread.size, "nav-contacts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES,
KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories")
NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
"Alerts" + (unreadNotes.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-alerts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, "Account", "nav-account")
KitIcon.Alerts, KitIcon.Alerts, "Alerts", unreadNotes, "nav-alerts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT,
KitIcon.Account, KitIcon.Account, "Account", 0, "nav-account")
}
}
/**
* One tab: a drawn icon, and a count when there is one.
*
* 🔴 **No label.** The kit's bar is four icons and nothing else — the artboard
* is literally named "Bottom Bar/Without Label". This was four `TextButton`s
* reading "Contacts Stories Alerts Account", which is a row of links, and
* the counts were parenthesised into those labels: "Contacts (3)".
*
* ⚠️ The label survives as the accessibility name. Dropping the text from the
* screen must not drop it from VoiceOver/TalkBack, or the bar becomes four
* unnamed buttons — the version of this that shipped as icons-only with no
* `contentDescription` would be strictly worse than the text it replaced.
*/
@Composable
private fun NavButton(
private fun RowScope.NavButton(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
screen: MessengerViewModel.Phase.Screen,
icon: androidx.compose.ui.graphics.vector.ImageVector,
activeIcon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
badge: Int,
tag: String,
) {
TextButton(
onClick = {
model.showScreen(screen)
if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) {
model.refreshNotifications()
val selected = phase.screen == screen
Box(
modifier = Modifier
.weight(1f)
.height(Kit.pillHeight)
.clickable {
model.showScreen(screen)
if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) {
model.refreshNotifications()
}
}
},
modifier = Modifier.testTag(tag),
.semantics {
contentDescription =
if (badge > 0) "$label, $badge unread" else label
}
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (phase.screen == screen) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
Icon(
if (selected) activeIcon else icon,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary
else Palette.SecondaryText,
modifier = Modifier.size(Kit.icon),
)
if (badge > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = Space.md, end = Space.lg)
.size(Space.sm)
.clip(CircleShape)
.background(Palette.Pink),
)
}
}
}
@@ -532,13 +573,13 @@ internal fun ColumnScope.SignedIn(
modifier = Modifier.weight(1f),
)
BarAction(
symbol = if (creatingGroup) "×" else "⊞",
icon = if (creatingGroup) KitIcon.Close else KitIcon.NewGroup,
label = "New group",
tag = "new-group",
onClick = { creatingGroup = !creatingGroup },
)
BarAction(
symbol = if (addingContact) "×" else "+",
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
label = "Add contact",
tag = "add-contact",
onClick = { addingContact = !addingContact },
@@ -602,7 +643,12 @@ internal fun ColumnScope.SignedIn(
* anyone who cannot see them.
*/
@Composable
private fun BarAction(symbol: String, label: String, tag: String, onClick: () -> Unit) {
private fun BarAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
tag: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
@@ -612,7 +658,15 @@ private fun BarAction(symbol: String, label: String, tag: String, onClick: () ->
.testTag(tag),
contentAlignment = Alignment.Center,
) {
Text(symbol, fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
// 🔴 An `Icon`, not a glyph in a text run. See `KitIcons.kt` — the bar
// used to draw "⊞" and "+" as characters, which is most of why this
// screen read as text links rather than as chrome.
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -61,7 +62,12 @@ fun ColumnScope.StoriesFeed(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Text("◎", fontSize = 32.sp, color = Palette.SecondaryText)
Icon(
KitIcon.Stories,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"A story goes to your contacts and disappears after a day — on "