diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index fb3487a..487210d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -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), ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index b501717..01f1d84 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt index 23f4262..274626a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -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), + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt index 7b874fa..edcecd2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -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) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt new file mode 100644 index 0000000..fa2304a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -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 +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index a7b7172..c4f9c4d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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), + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 95d6772..7a6e36d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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 " diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 57a32d9..80b61b9 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -144,8 +144,8 @@ class ScreenshotTest { peerName = "Hoang Nguyen", status = "On a call", actions = listOf( - CallAction("mute", "🎤", "Mute") {}, - CallAction("end", "✕", "End", isPrimary = true, + CallAction("mute", KitIcon.Mic, "Mute") {}, + CallAction("end", KitIcon.CallEnd, "End", isPrimary = true, tint = androidx.compose.material3.MaterialTheme.colorScheme.error, iconColor = androidx.compose.ui.graphics.Color.White) {}, ), @@ -157,10 +157,10 @@ class ScreenshotTest { peerName = "Hoang Nguyen", status = "Incoming call", actions = listOf( - CallAction("decline", "✕", "Decline", + CallAction("decline", KitIcon.CallEnd, "Decline", tint = androidx.compose.material3.MaterialTheme.colorScheme.error, iconColor = androidx.compose.ui.graphics.Color.White) {}, - CallAction("accept", "☎", "Answer", isPrimary = true, + CallAction("accept", KitIcon.Call, "Answer", isPrimary = true, tint = Palette.Teal, iconColor = androidx.compose.ui.graphics.Color.White) {}, ),