From b5b78045d7a3358525ddf848c32a1c33e4dea589 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:29:11 -0400 Subject: [PATCH] feat(navigation): label the tab bar Adds a name under each tab glyph (Scan, Chat, Wallet, You) per node 10642:1325, and titles the Chat screen "Chat" from the same string. - The name uses caption with its 18sp line height dropped. The theme has no 10sp token for the design's Demi 10 on a 12pt line. - Glyph and name share a column padded 5dp top and bottom, and the selected pill takes the row's height. Glyphs are 28dp to match the design. - The bar is inset 25dp from each screen edge (the design's 24pt). The old 40dp inset left each item nearly square, so the pill read as a circle. - As on the iOS bar, the selected pill reaches past its item, 10dp on each side, into matching side padding in the bar. The extra width keeps a "99+" badge inside the pill's round ends. - The unread badge follows node 10599:65444: 17.5dp tall, starting 16dp into the glyph and rising 3.25dp above it. Longer counts grow right from that start. The glyph's box is fixed to its size so a wide badge can't pull the glyph off centre. --- .../app/internal/ui/AppNavigationBar.kt | 4 +- .../com/flipcash/app/core/ui/NavigationBar.kt | 88 ++++++++++++++----- .../core/src/main/res/values/strings.xml | 7 +- .../com/flipcash/app/tipping/ChatsScreen.kt | 2 +- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 1441684807..096c02e30b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -127,7 +127,9 @@ internal fun AppNavigationBar( NavigationBar( modifier = Modifier .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.grid.x8) + // The design insets the bar 24pt from each edge (node 10642:1325), which + // keeps each tab's pill wider than tall; 25 is the nearest fixed step. + .padding(horizontal = CodeTheme.dimens.staticGrid.x5) .padding(bottom = CodeTheme.dimens.grid.x3), state = state, onButtonClick = { button -> diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 048325f8a1..42448c39b7 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,9 +21,12 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState @@ -37,9 +42,12 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.flipcash.app.core.navigation.NavBarButton @@ -104,8 +112,17 @@ fun NavigationBar( val order = NavBarButton.tabs if (order.isEmpty()) return - val iconSize = CodeTheme.dimens.staticGrid.x6 - val itemHeight = iconSize + CodeTheme.dimens.staticGrid.x2 * 2 + // The design's glyphs are 28pt (node 10599:65384), which sits off the 5dp grid. + val iconSize = 28.dp + // The design frames glyph and name with 5pt above and below in an 86.5 x 50pt item (node + // 10599:65384). The item wraps its column rather than fixing a height, so a large font scale + // grows the bar instead of clipping the name. + val itemPadding = CodeTheme.dimens.staticGrid.x1 + val indicatorOverhang = CodeTheme.dimens.staticGrid.x2 + // The design sets the name in Demi 10 on a 12pt line (node 10599:65384). caption is the + // theme's smallest type token; its 18sp line would leave the name floating in slack under the + // glyph, so the name takes the font's own line height instead. + val labelStyle = CodeTheme.typography.caption.copy(lineHeight = TextUnit.Unspecified) val selectedIndex = order.indexOf(state.selectedTab) .takeIf { it >= 0 && it <= order.lastIndex } ?: order.indexOf(NavBarButton.Wallet) @@ -146,21 +163,26 @@ fun NavigationBar( .fillMaxWidth() .then(modifier) .then(pillBackground) - .padding(CodeTheme.dimens.grid.x1), + .padding(vertical = CodeTheme.dimens.grid.x1) + .padding(horizontal = CodeTheme.dimens.grid.x1 + indicatorOverhang), ) { val itemWidth = maxWidth / order.size - // Selected-state pill that slides to the active tab, drawn behind the icons. + // Selected-state pill that slides to the active tab, drawn behind the icons. It takes the + // row's height rather than one of its own, so it always frames the whole item, and reaches + // indicatorOverhang past the item on each side into the bar's side padding, as the iOS bar + // does. That leaves the end pills the same gap to the bar's edge as above and below. val indicatorOffset by animateDpAsState( - targetValue = itemWidth * selectedIndex, + targetValue = itemWidth * selectedIndex - indicatorOverhang, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "navBarIndicatorOffset", ) Box( modifier = Modifier + .matchParentSize() .offset { IntOffset(indicatorOffset.roundToPx(), 0) } - .width(itemWidth) - .height(itemHeight) + .wrapContentWidth(Alignment.Start, unbounded = true) + .width(itemWidth + indicatorOverhang * 2) .background(Color.White.copy(alpha = 0.2f), CircleShape), ) @@ -172,10 +194,9 @@ fun NavigationBar( label = "navBarIconAlpha", ) val badgeCount = state.badgeCount(button) - Box( + Column( modifier = Modifier .weight(1f) - .height(itemHeight) .testTag(button.testTag) // Deliberately unclipped: the unread badge overhangs the icon's top-right // corner and a clip would shave it. Safe because the click indication is @@ -187,10 +208,13 @@ fun NavigationBar( indication = null, onLongClick = onYouTabLongClick.takeIf { button == NavBarButton.TipCard }, onClick = { onButtonClick(button) }, - ), - contentAlignment = Alignment.Center, + ) + .padding(vertical = itemPadding), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Box { + // Sized to the glyph so a wide badge overflows it instead of widening it, + // which would pull the glyph off the item's centre. + Box(modifier = Modifier.size(iconSize)) { if (button == NavBarButton.TipCard && avatar != null) { // The photo slot has its own unselected state (node 9713:664): the ring // thins from 2dp to 1dp and drops to white at 50%, which the slot's @@ -235,17 +259,32 @@ fun NavigationBar( contentDescription = null, ) } - // Overlaps the glyph's top-right corner (matching the iOS bar) rather than - // floating detached above it. Full opacity regardless of tab selection — - // the count must stay readable on an unselected tab. + // Overlaps the glyph's top-right corner (node 10599:65444): a 17.5pt circle + // starting 16pt into the glyph and rising 3.25pt above it, which leaves a + // sliver of the pill above. Anchored by its start so a longer count ("12", + // "99+") grows into the empty side of the item rather than over the glyph. + // Full opacity regardless of tab selection — the count must stay readable + // on an unselected tab. Badge( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), + .align(Alignment.TopStart) + .offset(x = 16.dp, y = (-3.25).dp) + .wrapContentSize(Alignment.TopStart, unbounded = true), count = badgeCount, color = CodeTheme.colors.indicator, + textStyle = CodeTheme.typography.caption.copy(lineHeight = TextUnit.Unspecified), + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.staticGrid.x1), + height = 17.5.dp, ) } + Text( + text = stringResource(button.label), + style = labelStyle, + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.graphicsLayer { alpha = iconAlpha }, + ) } } } @@ -253,9 +292,9 @@ fun NavigationBar( } /** - * Stable UI-test anchor per tab. The bar is icon-only -- no labels, and the glyphs carry no content - * description -- so without these ids the tabs are unaddressable from Maestro/UiAutomator. These are - * what `maestro/subflows/navigate_to_*.yaml` tap; keep them in sync with those flows. + * Stable UI-test anchor per tab. The glyphs carry no content description, so these ids address a + * tab without depending on its label text or locale. They are what + * `maestro/subflows/navigate_to_*.yaml` tap; keep them in sync with those flows. */ internal val NavBarButton.testTag: String get() = when (this) { @@ -265,6 +304,15 @@ internal val NavBarButton.testTag: String NavBarButton.TipCard -> "nav_tipcard" } +/** The tab's name under its glyph, in tab order Scan, Chat, Wallet, You (node 10642:1325). */ +private val NavBarButton.label: Int + get() = when (this) { + NavBarButton.Scanner -> R.string.title_tabScan + NavBarButton.Chats -> R.string.title_tabChat + NavBarButton.Wallet -> R.string.title_tabWallet + NavBarButton.TipCard -> R.string.title_tabYou + } + /** * The glyph for [this] tab at the weight its selection calls for (node 10000:111297). Every tab is * drawn as an outline until it is selected, where it fills in; the dimming on top of that is the diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 097ef1c9ff..869d0d3066 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1054,7 +1054,6 @@ Tips Tips - Chats No Chats Yet Start a new chat, or share your profile @@ -1326,4 +1325,10 @@ Display Name Profile Picture + + Scan + Chat + Wallet + You + diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 5eb5223d08..75ff7ef199 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -98,7 +98,7 @@ fun ChatsScreen() { size = size.copy(height = scrimHeight), ) }, - title = stringResource(R.string.title_chats), + title = stringResource(R.string.title_tabChat), // Centred rather than flush-start: an empty leading slot reserves no width, so a // Start title sits at the inset and reads as off-centre against the Add button. titleAlignment = Alignment.CenterHorizontally,