Files and Wallet are bar destinations, like iOS (android)

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 10:04:57 -04:00
co-authored by Claude Opus 5
parent 090ca7ed57
commit 832cbf9214
5 changed files with 223 additions and 76 deletions
@@ -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() }
@@ -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
@@ -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<Tab> = 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
@@ -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 ->
@@ -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",
)
}
}