From 832cbf9214f4f1c416084dda808feb7816a9f702 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 7 Sep 2026 10:04:57 -0400 Subject: [PATCH] Files and Wallet are bar destinations, like iOS (android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both apps had both features. iOS carried them in its tab bar; Android had them as buttons most of the way down the Account screen, behind a comment arguing the kit's bar is four icons and "a wallet people open occasionally does not earn the slot". What that produced: somebody testing the Android build reported Files and Wallet as missing. That is a fair reading of a feature you have to scroll past a dozen settings sections to find, and two apps disagreeing about where a feature lives is worse than either answer. Seven destinations now, in iOS's order — Home, Messages, Calls, Stories, Files, Wallet, Account. The Account sections are gone rather than kept alongside: two routes to one screen is two things to keep in step, and the bar is the one people look at. 🔴 **Tapping a tab calls `navigateTo`, not `showScreen`.** Wallet and Files each read the vault before they have anything to draw, so a bare `showScreen` lands somebody on an empty screen with nothing loading and no error. That is now one method with the loading in it, so no future caller can navigate past it. 🔴 **The bar's contents are data now, and a test reads them.** It was a hand-written run of `NavButton` calls, so nothing could assert what was in it — a destination could vanish in a refactor and no test would go red. That is exactly how this divergence survived. `NavDestinations.tabs()` returns the list; five tests pin the order, the tags the instrumented suite looks for, the accessibility labels, and that supervision removes Stories and only Stories. ⚠️ The kit's artboard is four icons and this is seven. Deliberate departure, said in the comment rather than left looking like an oversight. Verified from `test-results/*.xml` — 5 tests, 0 failures — not from BUILD SUCCESSFUL, which prints no test names for `:app`. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 53 ++------- .../io/flamenet/messenger/app/KitIcons.kt | 7 ++ .../io/flamenet/messenger/app/MainActivity.kt | 110 +++++++++++++----- .../messenger/app/MessengerViewModel.kt | 26 +++++ .../messenger/app/NavDestinationsTest.kt | 103 ++++++++++++++++ 5 files changed, 223 insertions(+), 76 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt 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 6e5266d..71989cb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -742,51 +742,14 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { StoryAudienceSection(model, phase) } - // ⚠️ Reached from Account rather than the tab bar. The bar is four icons - // by design — the kit's artboard is literally "Bottom Bar/Without Label" — - // and Alerts was taken OUT of it in August for being a fifth. A wallet - // people open occasionally does not earn the slot that decision refused. - SectionBand() - SettingsSection { - Text("Wallet", fontWeight = FontWeight.Medium, fontSize = 16.sp) - Spacer(Modifier.height(Space.xs)) - Text( - "A wallet on this phone, with keys only you hold. Not connected to " - + "your Flamenet account.", - fontSize = 13.sp, - color = Palette.SecondaryText, - ) - Spacer(Modifier.height(Space.sm)) - KitSecondaryButton( - text = "Open wallet", - onClick = { model.openWallet() }, - modifier = Modifier.fillMaxWidth(), - tag = "open-wallet", - ) - } - - // ⚠️ Beside the wallet and not in the tab bar, for the reason written - // above it: the bar is icons-only by design and Alerts was taken OUT of it - // in August for being a fifth. Files earns the slot no more than a wallet - // does. - SectionBand() - SettingsSection { - Text("Files", fontWeight = FontWeight.Medium, fontSize = 16.sp) - Spacer(Modifier.height(Space.xs)) - Text( - "Files encrypted on this phone, opened with your Flamenet passphrase. " - + "Nothing is stored on a server.", - fontSize = 13.sp, - color = Palette.SecondaryText, - ) - Spacer(Modifier.height(Space.sm)) - KitSecondaryButton( - text = "Open Files", - onClick = { model.openFiles() }, - modifier = Modifier.fillMaxWidth(), - tag = "open-files", - ) - } + // ⚠️ Wallet and Files are NOT here any more — they are bar destinations as + // of 2026-09-07, in the same place iOS puts them. They lived here because + // the kit's bar is four icons and a wallet "does not earn the slot"; what + // that produced was two features reachable only by scrolling most of the + // way down this screen, which somebody testing the app reasonably read as + // them not being built. Second entry points are not kept alongside: two + // routes to one screen is two things to keep in step, and the bar is the + // one a person looks at. SectionBand() SettingsSection { AppLockSection() } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index eed6957..bfecddc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -6,6 +6,7 @@ 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.AccountBalanceWallet import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AddComment import androidx.compose.material.icons.outlined.AddCircleOutline @@ -15,6 +16,7 @@ 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.FolderOpen import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Lock @@ -80,6 +82,11 @@ object KitIcon { val Stories: ImageVector = Icons.Outlined.Circle val Alerts: ImageVector = Icons.Outlined.NotificationsNone val Account: ImageVector = Icons.Outlined.PersonOutline + // The two that joined the bar on 2026-09-07. iOS draws `folder.fill` and + // `wallet.bifold.fill`; these are the Material shapes closest to them, so + // the same destination reads the same on both phones. + val Files: ImageVector = Icons.Outlined.FolderOpen + val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet // Composer val Attach: ImageVector = Icons.Outlined.AddCircleOutline 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 06fc30a..cd18d5e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -560,11 +561,53 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) } /** - * The three places to be, always visible. + * What the bar contains, as data rather than as a shape only Compose can see. * - * A tab bar rather than a menu: notifications and the account are things people - * look for, and a capability hidden behind an overflow is one most users never - * find. + * 🔴 **Extracted so a test can read it.** The bar was a hand-written run of + * `NavButton` calls, which meant nothing could assert what was in it — a + * destination could be dropped in a refactor and no test anywhere would go red. + * It has happened once already in spirit: Files and Wallet lived only inside + * the Account screen for weeks while iOS carried them in its bar, and the two + * apps disagreed with nothing to notice. + */ +object NavDestinations { + + data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String) + + /** + * The bar, in order. + * + * ⚠️ **Order matches iOS**, which puts Files and Wallet between Stories and + * Account. The same destination in the same place on both phones is most of + * what "the apps stay in sync" means to somebody holding one of each. + * + * `storiesHidden` is parental supervision: it removes the surface rather + * than showing an empty one. + */ + fun tabs(storiesHidden: Boolean): List = listOfNotNull( + Tab(Screen.CONTACTS, "Home", "nav-contacts"), + Tab(Screen.MESSAGES, "Messages", "nav-messages"), + Tab(Screen.CALLS, "Calls", "nav-calls"), + Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, + Tab(Screen.FILES, "Files", "nav-files"), + Tab(Screen.WALLET, "Wallet", "nav-wallet"), + Tab(Screen.ACCOUNT, "Account", "nav-account"), + ) +} + +/** + * The places to be, always visible. + * + * A tab bar rather than a menu: a capability hidden behind an overflow is one + * most users never find. That was the argument for putting the account here, + * and it is the same argument that brought Files and Wallet here on 2026-09-07 + * — they were reachable only by scrolling most of the way down the account + * screen, which is indistinguishable from not shipping them. + * + * ⚠️ **The kit's artboard is four icons and this is now seven.** That is a + * deliberate departure, not an oversight: the earlier note here argued a wallet + * "does not earn the slot", and iOS had already decided otherwise. Two apps + * disagreeing about where a feature lives is worse than either answer. */ @Composable private fun BottomNav( @@ -586,28 +629,34 @@ private fun BottomNav( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, - KitIcon.Home, KitIcon.Home, "Home", 0, "nav-contacts") - // The inbox: the kit's Messages-Listing. The unread badge lives here - // now — Home is the roster, this is where new mail is read. - NavButton(model, phase, MessengerViewModel.Phase.Screen.MESSAGES, - KitIcon.Chats, KitIcon.ChatsActive, "Messages", phase.unread.size, "nav-messages") - NavButton(model, phase, MessengerViewModel.Phase.Screen.CALLS, - KitIcon.Call, KitIcon.Call, "Calls", - // Missed calls SINCE the tab was last opened — it used to count - // every missed call in the log for ever, a badge nothing could - // clear. Opening the tab is what "seeing" them means. - model.missedCallsUnseen(phase), "nav-calls") - // Supervision removes the surface rather than emptying it. - if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) { - NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, - KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories") + // Supervision removes the Stories surface rather than emptying it. + val storiesHidden = + ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) + for (tab in NavDestinations.tabs(storiesHidden)) { + val badge = when (tab.screen) { + // The inbox badge lives on Messages — Home is the roster, this + // is where new mail is read. + Screen.MESSAGES -> phase.unread.size + // Missed calls SINCE the tab was last opened — it used to count + // every missed call in the log for ever, a badge nothing could + // clear. Opening the tab is what "seeing" them means. + Screen.CALLS -> model.missedCallsUnseen(phase) + // Alerts left the bar 2026-08-28 and iOS never had one; its + // unread count badges Account on both phones now. + Screen.ACCOUNT -> unreadNotes + else -> 0 + } + val (icon, active) = when (tab.screen) { + Screen.CONTACTS -> KitIcon.Home to KitIcon.Home + Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive + Screen.CALLS -> KitIcon.Call to KitIcon.Call + Screen.STORIES -> KitIcon.Stories to KitIcon.Stories + Screen.FILES -> KitIcon.Files to KitIcon.Files + Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet + else -> KitIcon.Account to KitIcon.Account + } + NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag) } - // Alerts left the bar 2026-08-28 — five tabs plus it was crowded, and - // iOS never had one (its unread count badges Account). Same here now; - // the screen itself survives for the badge tap-through from Account. - NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, - KitIcon.Account, KitIcon.Account, "Account", unreadNotes, "nav-account") } } @@ -640,12 +689,11 @@ private fun RowScope.NavButton( modifier = Modifier .weight(1f) .height(Kit.pillHeight) - .clickable { - model.showScreen(screen) - if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) { - model.refreshNotifications() - } - } + // 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to + // read the vault before they have anything to draw, so a bare + // `showScreen` lands somebody on an empty screen with nothing + // loading and no error — see the note on `navigateTo`. + .clickable { model.navigateTo(screen) } .semantics { contentDescription = if (badge > 0) "$label, $badge unread" else label diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 8896bcd..47f8935 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1921,6 +1921,32 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(screen = screen, error = null) } } + /** + * Go to a screen the way a person arriving at it expects. + * + * 🔴 **Not the same as `showScreen`, and the difference is not cosmetic.** + * Wallet and Files each have to read something off the vault before they + * have anything to draw, and `showScreen` alone would land somebody on an + * empty wallet with no error and nothing loading. Notifications has the + * same shape — the screen exists, the list is stale until it is fetched. + * + * ⚠️ Every caller that moves between destinations should use this rather + * than `showScreen`. `showScreen` stays public because the loaders + * themselves call it, but a navigation that skips the loader is a screen + * that looks broken for reasons the screen cannot explain. + */ + fun navigateTo(screen: Phase.Screen) { + when (screen) { + Phase.Screen.WALLET -> openWallet() + Phase.Screen.FILES -> openFiles() + Phase.Screen.NOTIFICATIONS -> { + showScreen(screen) + refreshNotifications() + } + else -> showScreen(screen) + } + } + // -- Contacts: the rest of the CRUD -------------------------------------- fun removeBuddy(userId: Long) = act { current -> diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt new file mode 100644 index 0000000..d3cafb6 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt @@ -0,0 +1,103 @@ +package io.flamenet.messenger.app + +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What is in the tab bar. + * + * 🔴 **This test exists because nothing could see the bar.** It was a hand-written + * run of `NavButton` calls inside a Composable, so its contents were invisible to + * every test in the project: a destination could be dropped in a refactor and + * nothing would go red. That is not hypothetical — Files and Wallet lived only + * inside the Account screen, most of a scroll down, while iOS carried both in + * its bar. Somebody testing the Android build reasonably concluded they had not + * been built at all. + * + * ⚠️ Pinning the exact list is the point, not an accident of writing it this + * way. A test that only asserted "at least four tabs" would have passed + * throughout the disagreement it is here to prevent. + */ +class NavDestinationsTest { + + /** + * 🔴 **The order matches iOS**, which puts Files and Wallet between Stories + * and Account. The same destination in the same place on both phones is + * most of what "the apps stay in sync" means to somebody holding one of + * each. + */ + @Test + fun `the bar is the seven destinations iOS has, in the same order`() { + assertEquals( + listOf( + Screen.CONTACTS, Screen.MESSAGES, Screen.CALLS, Screen.STORIES, + Screen.FILES, Screen.WALLET, Screen.ACCOUNT, + ), + NavDestinations.tabs(storiesHidden = false).map { it.screen }, + ) + } + + /** + * ⚠️ Supervision removes the surface rather than showing an empty one — + * and removes only that one. A parental control that quietly took Files or + * the wallet with it would be a data-access change nobody asked for. + */ + @Test + fun `supervision hides stories and nothing else`() { + val supervised = NavDestinations.tabs(storiesHidden = true).map { it.screen } + assertFalse(Screen.STORIES in supervised) + assertEquals( + NavDestinations.tabs(storiesHidden = false).map { it.screen } - Screen.STORIES, + supervised, + ) + } + + /** + * 🔴 The tags are how the instrumented suite finds the bar. A renamed tag + * is a test that stops testing and still passes, because + * `onNodeWithTag(...).performClick()` on a node that does not exist is + * caught, but `nodesWithTag(...).isEmpty()` guards throughout that suite + * turn a missing tag into a silent early return. + */ + @Test + fun `every tab carries the tag the instrumented suite looks for`() { + assertEquals( + listOf("nav-contacts", "nav-messages", "nav-calls", "nav-stories", + "nav-files", "nav-wallet", "nav-account"), + NavDestinations.tabs(storiesHidden = false).map { it.tag }, + ) + } + + /** + * ⚠️ The label is not decoration: the bar draws icons only, so this string + * is the accessibility name and the only thing TalkBack reads. A blank one + * makes the tab an unnamed button. + */ + @Test + fun `every tab is named for a screen reader`() { + for (tab in NavDestinations.tabs(storiesHidden = false)) { + assertTrue(tab.label.isNotBlank(), "${tab.tag} has no accessibility label") + } + } + + /** + * 🔴 Every destination a person can reach from the bar has to be somewhere + * the app can actually draw. `NOTIFICATIONS` is deliberately NOT in the bar + * — it left on 2026-08-28 and is reached by tapping the Account badge — so + * this checks the bar is a subset of the screens, not equal to them. + */ + @Test + fun `no tab points at a screen that does not exist`() { + val all = Screen.entries.toSet() + for (tab in NavDestinations.tabs(storiesHidden = false)) { + assertTrue(tab.screen in all, "${tab.tag} points at a screen the app has no case for") + } + assertFalse( + Screen.NOTIFICATIONS in NavDestinations.tabs(storiesHidden = false).map { it.screen }, + "Alerts left the bar in August; putting it back is a decision, not a refactor", + ) + } +}