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:
co-authored by
Claude Opus 5
parent
090ca7ed57
commit
832cbf9214
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user