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:
co-authored by
Claude Opus 5
parent
c9de1f26b8
commit
682d691a04
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user