diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt index 4f19010..89447f1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.sp import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -79,16 +81,34 @@ fun AddressCard(fnid: String?, relay: String) { // stops scanning โ€” the default filtering is smooth. filterQuality = FilterQuality.None, contentScale = ContentScale.FillBounds, - modifier = Modifier.size(180.dp).testTag("address-qr"), + // โš ๏ธ 140, not 180. At 180 the code was the largest thing on + // the screen and pushed everything else below the fold โ€” + // and a QR only has to be big enough for a phone camera a + // foot away, which 140dp at this density is. + modifier = Modifier.size(140.dp).testTag("address-qr"), ) Spacer(Modifier.height(Space.sm)) } + // ๐Ÿ”ด **Broken at the `@`, not wherever the line runs out.** As one + // string it wrapped mid-hostname โ€” "โ€ฆ@rela / y.flamenet.io" โ€” and a + // relay name split across a line break is the kind of thing + // somebody retypes wrong. The fnid is a derived key and wraps + // anywhere without harm; the host must not. Text( - address, - style = MaterialTheme.typography.bodySmall, + fnid, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, textAlign = TextAlign.Center, modifier = Modifier.testTag("address-text"), ) + Text( + "@$relay", + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = Palette.SecondaryText, + maxLines = 1, + textAlign = TextAlign.Center, + ) Spacer(Modifier.height(Space.sm)) Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { OutlinedButton( 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 719a10f..ed88de3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -232,15 +232,22 @@ fun ColumnScope.BuddyList( fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) FontWeight.Bold else FontWeight.Medium, ) - // The handle beside the display name, because the handle is - // what you tell someone else to type. + // โš ๏ธ The handle is dropped when it is the same string as + // the display name, which is the common case โ€” it rendered + // as "fnreviewbeta / fnreviewbeta ยท online", the name twice + // and a status. The kit's second line is the last message; + // until the relay gives us one, presence alone is honest + // and the handle only earns its place when it differs. Text( listOfNotNull( - buddy.handle.takeIf { it.isNotBlank() }, + buddy.handle.takeIf { + it.isNotBlank() && !it.equals(name, ignoreCase = true) + }, buddy.presence.takeIf { it.isNotBlank() }, ).joinToString(" ยท "), fontSize = 14.sp, color = Palette.SecondaryText, + maxLines = 1, ) } if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) { @@ -354,7 +361,9 @@ fun ColumnScope.GroupsSection( var name by remember { mutableStateOf("") } var chosen by remember { mutableStateOf(setOf()) } - SectionLabel("Groups") + // โš ๏ธ No label for a section with nothing in it. Two words of furniture + // above zero rows is worse than the gap it fills. + if (shownGroupsExist(phase, query) || creating) SectionLabel("Groups") if (creating) { OutlinedTextField( @@ -397,16 +406,17 @@ fun ColumnScope.GroupsSection( ) } - // โš ๏ธ An empty list rendered NOTHING here โ€” no guidance, and no way to tell - // "you have no groups" from "the list did not load". Say which. - if (phase.groups.isEmpty()) { + // ๐Ÿ”ด **Loaded-and-empty says nothing; failed still speaks.** The two used + // to share one sentence, and the reason is worth keeping: an empty list + // rendering NOTHING gave no way to tell "you have no groups" from "the + // list did not load". But a line of prose in the middle of a list, for the + // ordinary state of a new account, is chrome โ€” and the section label above + // it made two lines of furniture for zero rows. A failure is still worth a + // sentence, because that one IS news. + if (phase.groups.isEmpty() && !phase.groupsLoaded) { Text( - if (phase.groupsLoaded) { - "No groups yet. Start one with your buddies." - } else { - "Couldn't load your groups. Check the connection and sign in again." - }, - style = MaterialTheme.typography.bodyMedium, + "Couldn't load your groups. Check the connection and sign in again.", + fontSize = 14.sp, color = Palette.SecondaryText, modifier = Modifier.testTag("groups-empty"), ) @@ -493,22 +503,48 @@ fun ColumnScope.NotificationsScreen( ) { // Same bar grammar as the list screen: title at 24 bold, the one action on // the trailing edge. + val unread = phase.notifications.count { !it.read } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text("Alerts", style = MaterialTheme.typography.headlineMedium) - KitChip( - text = "Mark all read", - selected = false, - onClick = model::markAllNotificationsRead, - tag = "mark-all-read", - ) + // โš ๏ธ Only when there is something to mark. It was offered + // unconditionally, so an empty screen showed a control whose only + // possible effect was nothing โ€” and it was the most prominent thing on + // that screen. + if (unread > 0) { + KitChip( + text = "Mark all read", + selected = false, + onClick = model::markAllNotificationsRead, + tag = "mark-all-read", + ) + } } if (phase.notifications.isEmpty()) { - Text("Nothing new.", fontSize = 14.sp, color = Palette.SecondaryText) + // ๐Ÿ”ด **An empty state, not a stray sentence under the title.** + // "Nothing new." tucked against the heading read as a subtitle of the + // heading rather than as the content of the screen, and left the other + // nine tenths of it blank with no explanation of what would ever + // appear there. + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.xl * 2), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Space.sm), + ) { + Text("๐Ÿ””", fontSize = 32.sp) + Text("Nothing new", fontSize = 16.sp, fontWeight = FontWeight.Medium) + Text( + "Contact requests and things that happened while you were away " + + "show up here.", + fontSize = 14.sp, + color = Palette.SecondaryText, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } return } @@ -531,7 +567,11 @@ fun ColumnScope.NotificationsScreen( horizontalArrangement = Arrangement.spacedBy(Kit.gutter), verticalAlignment = Alignment.CenterVertically, ) { - Avatar(name = note.kind, online = false) + // โš ๏ธ A glyph for the kind, not initials OF the kind โ€” that + // rendered "contact request" as "CR" and "missed call" as + // "MC", two letters that look like a person's initials and + // name nobody. + AlertGlyph(note.kind) Column(modifier = Modifier.weight(1f)) { Text( note.body.ifBlank { note.kind }, @@ -597,11 +637,14 @@ fun ColumnScope.AccountScreen( SettingsSection { PresencePicker(model, phase.presence) + // โš ๏ธ The row is the label; the field must not repeat it. "Display + // name" over a box whose placeholder also said "Display name" is the + // same duplication the contact rows had. SettingsRow(label = "Display name") {} OutlinedTextField( value = displayName, onValueChange = { displayName = it }, - placeholder = { Text("Display name", fontSize = 14.sp) }, + placeholder = { Text("How others see you", fontSize = 14.sp) }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("display-name"), ) @@ -622,7 +665,7 @@ fun ColumnScope.AccountScreen( OutlinedTextField( value = password, onValueChange = { password = it }, - placeholder = { Text("New password", fontSize = 14.sp) }, + placeholder = { Text("At least 8 characters", fontSize = 14.sp) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth().testTag("new-password"), @@ -865,6 +908,29 @@ private fun IconAction( } } +private fun shownGroupsExist( + phase: MessengerViewModel.Phase.SignedIn, + query: String, +): Boolean = phase.groups.any { query.isBlank() || it.name.contains(query, true) } + +/** One circle per kind of alert, in the same 48dp footprint as an avatar. */ +@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 -> "โ€ข" + } + Box( + modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill), + contentAlignment = Alignment.Center, + ) { + Text(symbol, fontSize = 20.sp, color = MaterialTheme.colorScheme.primary) + } +} + @Composable private fun SectionLabel(text: String) { Text( 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 9939fce..6dbccb0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -413,8 +413,13 @@ private fun NavButton( } } +/** + * โš ๏ธ Internal rather than private ONLY so the JVM screenshot suite can render + * it. `MainActivity` is its one real caller โ€” the alternative was a screenshot + * harness that could photograph the app's parts and never a whole screen. + */ @Composable -private fun ColumnScope.SignedIn( +internal fun ColumnScope.SignedIn( model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn, ) { @@ -443,19 +448,18 @@ private fun ColumnScope.SignedIn( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - phase.handle.ifBlank { "Signed in" }, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - ) - Text( - "${phase.relay} ยท ${phase.deviceId.take(8)}", - fontSize = 12.sp, - color = Palette.SecondaryText, - modifier = Modifier.testTag("device-id"), - ) - } + // ๐Ÿ”ด **The screen's name, not the account's.** The kit's large title + // says "Messages" โ€” it names where you are. This said "fnreviewalpha" + // over the relay and a device id, which is three lines about who you + // are at the top of a list about other people, and it dominated the + // screen because a handle is longer than a title. Identity belongs on + // Account, which is where it now is. + Text( + "Messages", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) BarAction( symbol = if (creatingGroup) "ร—" else "โŠž", label = "New group", 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 ea15d17..c19de63 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -69,6 +69,28 @@ class ScreenshotTest { AddressCard(fnid = null, relay = "relay.flamenet.io") } + /** + * A whole screen, with a fabricated phase and an idle view model. + * + * โš ๏ธ The screens take their state as `phase` and only touch the model when + * something is tapped, so an unstarted model is enough to render them. It + * does open the Keystore vault in `init` โ€” under Robolectric that fails + * inside a `runCatching` on a background dispatcher, which is why this + * works at all rather than by design. If it starts hanging, that is the + * first place to look. + */ + @Test fun contactsScreen() = shootFullScreen("screen-contacts") { model, phase -> + androidx.compose.foundation.layout.Column { SignedIn(model, phase) } + } + + @Test fun accountScreen() = shootFullScreen("screen-account") { model, phase -> + androidx.compose.foundation.layout.Column { AccountScreen(model, phase) } + } + + @Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase -> + androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) } + } + /** The kit's buttons, so a change to one is visible without hunting. */ @Test fun buttons() = shoot("buttons") { Column { @@ -86,6 +108,51 @@ class ScreenshotTest { * background the PNG has a transparent ground and every colour judgement * made from it is wrong. */ + /** A phase with enough in it to exercise every row shape on a screen. */ + private fun samplePhase() = MessengerViewModel.Phase.SignedIn( + handle = "fnreviewalpha", + userId = 31, + deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", + relay = "relay.flamenet.io", + fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn", + status = "Signed in on this device.", + buddies = listOf( + MessengerApi.Buddy(userId = 32, handle = "fnreviewbeta", + displayName = "fnreviewbeta", presence = "online"), + MessengerApi.Buddy(userId = 33, handle = "hoang", + displayName = "Hoang Nguyen", presence = "away"), + ), + buddiesLoaded = true, + groupsLoaded = true, + notifications = listOf( + MessengerApi.Notification(1, "contact request", + "Hoang Nguyen wants to add you", read = false), + MessengerApi.Notification(2, "missed call", + "You missed a call from fnreviewbeta", read = true), + ), + ) + + /** The same screen with nothing on it โ€” the state a new account sees. */ + @Test fun alertsEmpty() = shoot("screen-alerts-empty") { + val app = androidx.test.core.app.ApplicationProvider + .getApplicationContext() + androidx.compose.foundation.layout.Column { + NotificationsScreen(MessengerViewModel(app), + samplePhase().copy(notifications = emptyList())) + } + } + + private fun shootFullScreen( + name: String, + content: @Composable (MessengerViewModel, MessengerViewModel.Phase.SignedIn) -> Unit, + ) { + val app = androidx.test.core.app.ApplicationProvider + .getApplicationContext() + val model = MessengerViewModel(app) + val phase = samplePhase() + shoot(name) { content(model, phase) } + } + private fun shoot(name: String, content: @Composable () -> Unit) { compose.setContent { MessengerTheme {