@@ -11,6 +11,8 @@ import androidx.compose.foundation.combinedClickable
1111import androidx.compose.foundation.interaction.MutableInteractionSource
1212import androidx.compose.foundation.layout.Box
1313import androidx.compose.foundation.layout.BoxWithConstraints
14+ import androidx.compose.foundation.layout.Column
15+ import androidx.compose.foundation.layout.PaddingValues
1416import androidx.compose.foundation.layout.Row
1517import androidx.compose.foundation.layout.fillMaxSize
1618import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,9 +21,12 @@ import androidx.compose.foundation.layout.offset
1921import androidx.compose.foundation.layout.padding
2022import androidx.compose.foundation.layout.size
2123import androidx.compose.foundation.layout.width
24+ import androidx.compose.foundation.layout.wrapContentSize
25+ import androidx.compose.foundation.layout.wrapContentWidth
2226import androidx.compose.foundation.shape.CircleShape
2327import androidx.compose.material.icons.Icons
2428import androidx.compose.material.icons.filled.AccountCircle
29+ import androidx.compose.material.Text
2530import androidx.compose.runtime.Composable
2631import androidx.compose.runtime.getValue
2732import androidx.compose.runtime.produceState
@@ -37,9 +42,12 @@ import androidx.compose.ui.graphics.painter.Painter
3742import androidx.compose.ui.graphics.vector.rememberVectorPainter
3843import androidx.compose.ui.platform.testTag
3944import androidx.compose.ui.res.painterResource
45+ import androidx.compose.ui.res.stringResource
46+ import androidx.compose.ui.text.style.TextAlign
4047import androidx.compose.ui.tooling.preview.Preview
4148import androidx.compose.ui.tooling.preview.PreviewWrapper
4249import androidx.compose.ui.unit.IntOffset
50+ import androidx.compose.ui.unit.TextUnit
4351import androidx.compose.ui.unit.dp
4452import androidx.compose.ui.util.fastForEach
4553import com.flipcash.app.core.navigation.NavBarButton
@@ -104,8 +112,17 @@ fun NavigationBar(
104112 val order = NavBarButton .tabs
105113 if (order.isEmpty()) return
106114
107- val iconSize = CodeTheme .dimens.staticGrid.x6
108- val itemHeight = iconSize + CodeTheme .dimens.staticGrid.x2 * 2
115+ // The design's glyphs are 28pt (node 10599:65384), which sits off the 5dp grid.
116+ val iconSize = 28 .dp
117+ // The design frames glyph and name with 5pt above and below in an 86.5 x 50pt item (node
118+ // 10599:65384). The item wraps its column rather than fixing a height, so a large font scale
119+ // grows the bar instead of clipping the name.
120+ val itemPadding = CodeTheme .dimens.staticGrid.x1
121+ val indicatorOverhang = CodeTheme .dimens.staticGrid.x2
122+ // The design sets the name in Demi 10 on a 12pt line (node 10599:65384). caption is the
123+ // theme's smallest type token; its 18sp line would leave the name floating in slack under the
124+ // glyph, so the name takes the font's own line height instead.
125+ val labelStyle = CodeTheme .typography.caption.copy(lineHeight = TextUnit .Unspecified )
109126 val selectedIndex = order.indexOf(state.selectedTab)
110127 .takeIf { it >= 0 && it <= order.lastIndex }
111128 ? : order.indexOf(NavBarButton .Wallet )
@@ -146,21 +163,26 @@ fun NavigationBar(
146163 .fillMaxWidth()
147164 .then(modifier)
148165 .then(pillBackground)
149- .padding(CodeTheme .dimens.grid.x1),
166+ .padding(vertical = CodeTheme .dimens.grid.x1)
167+ .padding(horizontal = CodeTheme .dimens.grid.x1 + indicatorOverhang),
150168 ) {
151169 val itemWidth = maxWidth / order.size
152170
153- // Selected-state pill that slides to the active tab, drawn behind the icons.
171+ // Selected-state pill that slides to the active tab, drawn behind the icons. It takes the
172+ // row's height rather than one of its own, so it always frames the whole item, and reaches
173+ // indicatorOverhang past the item on each side into the bar's side padding, as the iOS bar
174+ // does. That leaves the end pills the same gap to the bar's edge as above and below.
154175 val indicatorOffset by animateDpAsState(
155- targetValue = itemWidth * selectedIndex,
176+ targetValue = itemWidth * selectedIndex - indicatorOverhang ,
156177 animationSpec = spring(stiffness = Spring .StiffnessMediumLow ),
157178 label = " navBarIndicatorOffset" ,
158179 )
159180 Box (
160181 modifier = Modifier
182+ .matchParentSize()
161183 .offset { IntOffset (indicatorOffset.roundToPx(), 0 ) }
162- .width(itemWidth )
163- .height(itemHeight )
184+ .wrapContentWidth( Alignment . Start , unbounded = true )
185+ .width(itemWidth + indicatorOverhang * 2 )
164186 .background(Color .White .copy(alpha = 0.2f ), CircleShape ),
165187 )
166188
@@ -172,10 +194,9 @@ fun NavigationBar(
172194 label = " navBarIconAlpha" ,
173195 )
174196 val badgeCount = state.badgeCount(button)
175- Box (
197+ Column (
176198 modifier = Modifier
177199 .weight(1f )
178- .height(itemHeight)
179200 .testTag(button.testTag)
180201 // Deliberately unclipped: the unread badge overhangs the icon's top-right
181202 // corner and a clip would shave it. Safe because the click indication is
@@ -187,10 +208,13 @@ fun NavigationBar(
187208 indication = null ,
188209 onLongClick = onYouTabLongClick.takeIf { button == NavBarButton .TipCard },
189210 onClick = { onButtonClick(button) },
190- ),
191- contentAlignment = Alignment .Center ,
211+ )
212+ .padding(vertical = itemPadding),
213+ horizontalAlignment = Alignment .CenterHorizontally ,
192214 ) {
193- Box {
215+ // Sized to the glyph so a wide badge overflows it instead of widening it,
216+ // which would pull the glyph off the item's centre.
217+ Box (modifier = Modifier .size(iconSize)) {
194218 if (button == NavBarButton .TipCard && avatar != null ) {
195219 // The photo slot has its own unselected state (node 9713:664): the ring
196220 // thins from 2dp to 1dp and drops to white at 50%, which the slot's
@@ -235,27 +259,42 @@ fun NavigationBar(
235259 contentDescription = null ,
236260 )
237261 }
238- // Overlaps the glyph's top-right corner (matching the iOS bar) rather than
239- // floating detached above it. Full opacity regardless of tab selection —
240- // the count must stay readable on an unselected tab.
262+ // Overlaps the glyph's top-right corner (node 10599:65444): a 17.5pt circle
263+ // starting 16pt into the glyph and rising 3.25pt above it, which leaves a
264+ // sliver of the pill above. Anchored by its start so a longer count ("12",
265+ // "99+") grows into the empty side of the item rather than over the glyph.
266+ // Full opacity regardless of tab selection — the count must stay readable
267+ // on an unselected tab.
241268 Badge (
242269 modifier = Modifier
243- .align(Alignment .TopEnd )
244- .offset(x = CodeTheme .dimens.staticGrid.x1, y = - CodeTheme .dimens.staticGrid.x1),
270+ .align(Alignment .TopStart )
271+ .offset(x = 16 .dp, y = (- 3.25 ).dp)
272+ .wrapContentSize(Alignment .TopStart , unbounded = true ),
245273 count = badgeCount,
246274 color = CodeTheme .colors.indicator,
275+ textStyle = CodeTheme .typography.caption.copy(lineHeight = TextUnit .Unspecified ),
276+ contentPadding = PaddingValues (horizontal = CodeTheme .dimens.staticGrid.x1),
277+ height = 17.5 .dp,
247278 )
248279 }
280+ Text (
281+ text = stringResource(button.label),
282+ style = labelStyle,
283+ color = Color .White ,
284+ textAlign = TextAlign .Center ,
285+ maxLines = 1 ,
286+ modifier = Modifier .graphicsLayer { alpha = iconAlpha },
287+ )
249288 }
250289 }
251290 }
252291 }
253292}
254293
255294/* *
256- * Stable UI-test anchor per tab. The bar is icon-only -- no labels, and the glyphs carry no content
257- * description -- so without these ids the tabs are unaddressable from Maestro/UiAutomator. These are
258- * what `maestro/subflows/navigate_to_*.yaml` tap; keep them in sync with those flows.
295+ * Stable UI-test anchor per tab. The glyphs carry no content description, so these ids address a
296+ * tab without depending on its label text or locale. They are what
297+ * `maestro/subflows/navigate_to_*.yaml` tap; keep them in sync with those flows.
259298 */
260299internal val NavBarButton .testTag: String
261300 get() = when (this ) {
@@ -265,6 +304,15 @@ internal val NavBarButton.testTag: String
265304 NavBarButton .TipCard -> " nav_tipcard"
266305 }
267306
307+ /* * The tab's name under its glyph, in tab order Scan, Chat, Wallet, You (node 10642:1325). */
308+ private val NavBarButton .label: Int
309+ get() = when (this ) {
310+ NavBarButton .Scanner -> R .string.title_tabScan
311+ NavBarButton .Chats -> R .string.title_tabChat
312+ NavBarButton .Wallet -> R .string.title_tabWallet
313+ NavBarButton .TipCard -> R .string.title_tabYou
314+ }
315+
268316/* *
269317 * The glyph for [this] tab at the weight its selection calls for (node 10000:111297). Every tab is
270318 * drawn as an outline until it is selected, where it fills in; the dimming on top of that is the
0 commit comments