Files
flamenet-messenger-android/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt
T
Paul HittandClaude Opus 5 8fa66cd603 White text boxes — matches ios!144
An input is the one surface a person types into and reads back character by
character, and a dark field on a dark ground gave it the least contrast on the
screen at the moment it mattered most. Typed text reads 18.3:1 on white; the
placeholder 4.6:1, because a placeholder is still text.

⚠️ **Its own colour, not `SurfaceAlt`.** That fill is shared with incoming
bubbles and the tab pill, which stay dark — a white message bubble on this
ground would read as somebody else's app.

🔴 **Every colour a field draws has to be named, not just the container.**
Material derives label, placeholder, cursor and outline from the colour scheme,
which is the DARK scheme here: setting only `containerColor` leaves a
light-on-dark placeholder on a white box, and it simply disappears. iOS had the
same failure in a different dialect — SwiftUI drew the placeholder and caret for
a dark background until the field was given `.environment(\.colorScheme, .light)`.
Two frameworks, one mistake: a white island in a dark app has to be told it is
an island.

One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call
sites. Mechanical and wide rather than a new wrapper composable, because a
wrapper would have to be adopted one screen at a time and the ones that were
missed would be exactly the ones nobody looks at.

⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in
the chat, not a form field, and turning it white would put a white bar across
the bottom of every conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:11:20 -04:00

353 lines
13 KiB
Kotlin

package io.flamenet.messenger.app
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.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
/**
* The kit's `07 Messages-Listing` as its own tab: large title, search box,
* the Recent circles row, then a flat list of conversations.
*
* 🔴 **Conversations, not contacts.** Home lists everybody and manages the
* roster; this screen is the inbox — people you talk to, unread first. Same
* split iOS ships, so the two apps stay one product.
*/
@Composable
fun ColumnScope.MessagesScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val query = remember { mutableStateOf("") }
val creatingGroup = remember { mutableStateOf(false) }
var addingContact by remember { mutableStateOf(false) }
var scanning by remember { mutableStateOf(false) }
var scannedPrefill by remember { mutableStateOf("") }
val scanContext = androidx.compose.ui.platform.LocalContext.current
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { _ ->
// Open either way — the dialog explains itself when access is refused.
scanning = true
}
// Starting a group sits on the Groups heading and a chat on the Chats
// heading; the bar keeps the two ways a NEW person gets in — scan their
// code, or add them by name — the same pair the Home bar carries.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Messages",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
androidx.compose.material3.Icon(
KitIcon.Scan,
contentDescription = "Scan an address",
tint = Palette.Accent,
modifier = Modifier
.size(24.dp)
.clickable {
val has = androidx.core.content.ContextCompat.checkSelfPermission(
scanContext, android.Manifest.permission.CAMERA
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) scanning = true
else askCamera.launch(android.Manifest.permission.CAMERA)
}
.testTag("messages-scan"),
)
Spacer(Modifier.width(Space.md))
androidx.compose.material3.Icon(
if (addingContact) KitIcon.Close else KitIcon.Add,
contentDescription = "Add contact",
tint = Palette.Accent,
modifier = Modifier
.size(26.dp)
.clickable { addingContact = !addingContact }
.testTag("messages-add-contact"),
)
}
if (scanning) {
ScanAddressDialog(
onFound = { raw ->
scanning = false
parseScannedAddress(raw)?.let { card ->
scannedPrefill = if (card.handle != null &&
card.relay.equals(phase.relay, ignoreCase = true)
) card.handle else "${card.fnid}@${card.relay}"
addingContact = true
}
},
onDismiss = { scanning = false },
)
}
if (addingContact) {
AddContactForm(model, phase, initial = scannedPrefill) {
addingContact = false
scannedPrefill = ""
}
}
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
colors = fieldColors(),
value = query.value,
onValueChange = { query.value = it },
placeholder = { Text("Search conversations") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
)
Spacer(Modifier.height(Space.md))
// ⚠️ Above the conversation list. Somebody searching for a word they
// remember is looking for the message, not the person — name matches are
// already covered by the list underneath.
MessageHits(model, query.value)
BuddyCirclesRow(model, phase)
Spacer(Modifier.height(Space.sm))
Divider()
Spacer(Modifier.height(Space.sm))
// The kit's listing holds every conversation — groups included.
// ⚠️ No label of our own above the groups: GroupsSection draws its own
// (and only when there is something to head). A second one here printed
// "GROUPS" twice.
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
Spacer(Modifier.height(Space.sm))
val showNewChat = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"CHATS",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = androidx.compose.ui.unit.TextUnit(
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
modifier = Modifier.weight(1f),
)
// The way in, beside the label — exactly as New group sits beside its
// heading in the bar above.
androidx.compose.material3.Icon(
KitIcon.NewChat,
contentDescription = "New chat",
tint = Palette.Accent,
modifier = Modifier
.size(22.dp)
.clickable { showNewChat.value = true }
.testTag("new-chat"),
)
}
if (showNewChat.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showNewChat.value = false },
title = { Text("New chat") },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first — on Home, with their username or address.")
} else {
Column {
phase.buddies.forEach { buddy ->
val rowName = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showNewChat.value = false
model.openChat(buddy.userId, rowName)
}
.padding(vertical = 10.dp)
.testTag("new-chat-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
name = rowName,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
size = 36.dp,
)
Spacer(Modifier.width(Space.sm))
Text(
rowName,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { showNewChat.value = false }) { Text("Cancel") }
},
)
}
val shown = phase.buddies
.filter {
query.value.isBlank() ||
it.displayName.contains(query.value, true) ||
it.handle.contains(query.value, true)
}
.sortedWith(
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
Vault.threadForUser(it.userId) in phase.unread
}.thenBy { it.displayName.ifBlank { it.handle } }
)
if (shown.isEmpty()) {
Text(
if (phase.buddies.isEmpty()) {
"No conversations yet. Add someone on Home — everyone you add is a tap away."
} else {
"Nothing matches that search."
},
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = Space.lg),
)
return
}
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
val unread = Vault.threadForUser(buddy.userId) in phase.unread
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openChat(buddy.userId, name) }
.padding(vertical = Kit.rowGap / 2)
.testTag("conversation-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Avatar(
name = name,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
)
Column(modifier = Modifier.weight(1f)) {
Text(
name,
fontSize = 16.sp,
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
if (unread) "New messages" else buddy.presence,
fontSize = 12.sp,
color = if (unread) MaterialTheme.colorScheme.primary
else Palette.SecondaryText,
)
}
if (unread) UnreadDot()
}
Divider(modifier = Modifier.padding(start = 64.dp))
}
}
}
/** The kit's unread marker: a small filled circle in the row's trailing slot. */
@Composable
private fun UnreadDot() {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
/**
* What a search found inside conversations, rather than in their names.
*
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
* device — the cost the iPhone app does not pay, because it keeps the whole
* history in memory.
*/
@Composable
private fun MessageHits(model: MessengerViewModel, query: String) {
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
LaunchedEffect(query) {
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
hits = emptyList()
return@LaunchedEffect
}
kotlinx.coroutines.delay(250)
hits = model.searchMessages(query)
}
if (hits.isEmpty()) return
Text(
"IN MESSAGES",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = 0.8.sp,
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
)
for (hit in hits) {
Column(
Modifier.fillMaxWidth()
.padding(horizontal = Space.md, vertical = Space.sm)
.testTag("search-hit")
) {
Text(
hit.snippet,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(Space.sm))
Divider()
Spacer(Modifier.height(Space.sm))
}