Skip to content

Commit 304e342

Browse files
authored
feat(navigation): label the tab bar (#1629)
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.
1 parent 05afee2 commit 304e342

4 files changed

Lines changed: 78 additions & 23 deletions

File tree

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ internal fun AppNavigationBar(
127127
NavigationBar(
128128
modifier = Modifier
129129
.navigationBarsPadding()
130-
.padding(horizontal = CodeTheme.dimens.grid.x8)
130+
// The design insets the bar 24pt from each edge (node 10642:1325), which
131+
// keeps each tab's pill wider than tall; 25 is the nearest fixed step.
132+
.padding(horizontal = CodeTheme.dimens.staticGrid.x5)
131133
.padding(bottom = CodeTheme.dimens.grid.x3),
132134
state = state,
133135
onButtonClick = { button ->

‎apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt‎

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import androidx.compose.foundation.combinedClickable
1111
import androidx.compose.foundation.interaction.MutableInteractionSource
1212
import androidx.compose.foundation.layout.Box
1313
import androidx.compose.foundation.layout.BoxWithConstraints
14+
import androidx.compose.foundation.layout.Column
15+
import androidx.compose.foundation.layout.PaddingValues
1416
import androidx.compose.foundation.layout.Row
1517
import androidx.compose.foundation.layout.fillMaxSize
1618
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,9 +21,12 @@ import androidx.compose.foundation.layout.offset
1921
import androidx.compose.foundation.layout.padding
2022
import androidx.compose.foundation.layout.size
2123
import androidx.compose.foundation.layout.width
24+
import androidx.compose.foundation.layout.wrapContentSize
25+
import androidx.compose.foundation.layout.wrapContentWidth
2226
import androidx.compose.foundation.shape.CircleShape
2327
import androidx.compose.material.icons.Icons
2428
import androidx.compose.material.icons.filled.AccountCircle
29+
import androidx.compose.material.Text
2530
import androidx.compose.runtime.Composable
2631
import androidx.compose.runtime.getValue
2732
import androidx.compose.runtime.produceState
@@ -37,9 +42,12 @@ import androidx.compose.ui.graphics.painter.Painter
3742
import androidx.compose.ui.graphics.vector.rememberVectorPainter
3843
import androidx.compose.ui.platform.testTag
3944
import androidx.compose.ui.res.painterResource
45+
import androidx.compose.ui.res.stringResource
46+
import androidx.compose.ui.text.style.TextAlign
4047
import androidx.compose.ui.tooling.preview.Preview
4148
import androidx.compose.ui.tooling.preview.PreviewWrapper
4249
import androidx.compose.ui.unit.IntOffset
50+
import androidx.compose.ui.unit.TextUnit
4351
import androidx.compose.ui.unit.dp
4452
import androidx.compose.ui.util.fastForEach
4553
import 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
*/
260299
internal 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

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,6 @@
10541054

10551055
<string name="action_tips">Tips</string>
10561056
<string name="title_tips">Tips</string>
1057-
<string name="title_chats">Chats</string>
10581057
<string name="title_noChatsYet">No Chats Yet</string>
10591058
<string name="description_noChatsYet">Start a new chat, or share your profile</string>
10601059

@@ -1326,4 +1325,10 @@
13261325
<string name="title_displayName">Display Name</string>
13271326
<string name="title_profilePicture">Profile Picture</string>
13281327

1328+
<!-- Tab bar labels, in tab order (node 10642:1325). -->
1329+
<string name="title_tabScan">Scan</string>
1330+
<string name="title_tabChat">Chat</string>
1331+
<string name="title_tabWallet">Wallet</string>
1332+
<string name="title_tabYou">You</string>
1333+
13291334
</resources>

‎apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ fun ChatsScreen() {
9898
size = size.copy(height = scrimHeight),
9999
)
100100
},
101-
title = stringResource(R.string.title_chats),
101+
title = stringResource(R.string.title_tabChat),
102102
// Centred rather than flush-start: an empty leading slot reserves no width, so a
103103
// Start title sits at the inset and reads as off-centre against the Add button.
104104
titleAlignment = Alignment.CenterHorizontally,

0 commit comments

Comments
 (0)