Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
)

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -235,27 +259,42 @@ 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 },
)
}
}
}
}
}

/**
* 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) {
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,6 @@

<string name="action_tips">Tips</string>
<string name="title_tips">Tips</string>
<string name="title_chats">Chats</string>
<string name="title_noChatsYet">No Chats Yet</string>
<string name="description_noChatsYet">Start a new chat, or share your profile</string>

Expand Down Expand Up @@ -1326,4 +1325,10 @@
<string name="title_displayName">Display Name</string>
<string name="title_profilePicture">Profile Picture</string>

<!-- Tab bar labels, in tab order (node 10642:1325). -->
<string name="title_tabScan">Scan</string>
<string name="title_tabChat">Chat</string>
<string name="title_tabWallet">Wallet</string>
<string name="title_tabYou">You</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading