From 55db99eb7a3892bd2faa3995e64e8c7e0a332b2f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 16:12:33 -0400 Subject: [PATCH] feat(navigation): give the You tab's photo slot an unselected state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The photo slot wore the same 2dp solid white ring whether or not the tab was selected, so the only unselected cue was the slot's 0.5 alpha. Node 9713:664 draws the unselected ring thinner and dimmer: 1px white at 50%, which the slot alpha halves again to the 25% the frame composes to. Animate the ring width alongside the existing icon fade, and take the ring colour's alpha from that same animation so the two stay in step. The padding around the ring stays at thickBorder in both states, so the photo doesn't resize as the ring thins. The existing preview passes no avatar, so neither photo state was visible in previews. Add one per state, plus a Robolectric capture of all three states in the shape of the two screenshot tests already in this module — at mdpi, where a dp is a pixel and the ring measures against the design's px values directly. --- .../com/flipcash/app/core/ui/NavigationBar.kt | 54 +++++++++-- .../ui/NavigationBarAvatarScreenshotTest.kt | 94 +++++++++++++++++++ 2 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarAvatarScreenshotTest.kt 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 828968f233..df0f2764b9 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 @@ -88,10 +88,10 @@ fun NavigationBar( state: NavigationBarState, onButtonClick: (NavBarButton) -> Unit = {}, hazeState: HazeState? = null, - // The You tab wears the account's own photo in place of its glyph once one is set (node - // 9641:17130). This module sits below the profile layer, so the caller supplies the avatar and - // passes null when there is no photo. The modifier handed back already sizes, clips and fades - // the slot; the avatar only has to fill it. + // The You tab wears the account's own photo in place of its glyph once one is set (nodes + // 9641:17130 and 9713:664). This module sits below the profile layer, so the caller supplies the + // avatar and passes null when there is no photo. The modifier handed back already sizes, clips + // and fades the slot; the avatar only has to fill it. avatar: (@Composable (Modifier) -> Unit)? = null, ) { val order = NavBarButton.tabs @@ -181,6 +181,18 @@ fun NavigationBar( ) { Box { 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 + // 0.5 alpha then halves again. The photo itself keeps its size — the + // ring is inset, and the padding around it does not change. + val ringWidth by animateDpAsState( + targetValue = if (selected) { + CodeTheme.dimens.thickBorder + } else { + CodeTheme.dimens.border + }, + label = "navBarAvatarRingWidth", + ) Box( modifier = Modifier .size(iconSize) @@ -189,13 +201,15 @@ fun NavigationBar( ) { avatar(Modifier.fillMaxSize().clip(CircleShape)) // Drawn over the photo rather than behind it, so the ring survives - // whatever background the avatar paints for itself. + // whatever background the avatar paints for itself. iconAlpha is + // the ring's own fade, on top of the slot's — the two compose to + // the 25% the unselected ring reads at. Box( modifier = Modifier .fillMaxSize() .border( - CodeTheme.dimens.thickBorder, - Color.White, + ringWidth, + Color.White.copy(alpha = iconAlpha), CircleShape, ), ) @@ -262,3 +276,29 @@ private fun NavigationBarPreview() { onButtonClick = { } ) } + +/** + * The You tab's photo slot in both of its states (node 9713:664) — a flat fill stands in for the + * photo, since a preview has no profile to read. + */ +@Preview(name = "Avatar, You tab unselected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarUnselectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} + +@Preview(name = "Avatar, You tab selected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarSelectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} diff --git a/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarAvatarScreenshotTest.kt b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarAvatarScreenshotTest.kt new file mode 100644 index 0000000000..604f65ee91 --- /dev/null +++ b/apps/flipcash/core-ui/src/test/kotlin/com/flipcash/app/core/ui/NavigationBarAvatarScreenshotTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.core.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.theme.FlipcashPreview +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the You tab's photo slot in both selection states to a PNG so the ring can be eyeballed + * against node 9713:664 without an emulator. Not an assertion test — it captures to + * `build/screenshots/`, and scales the capture up so a 1dp/2dp ring difference is visible. + * + * Frames are pumped on a paused clock rather than waiting for idle, like + * [TokenCardWatermarkScreenshotTest]: the bar's selection animations keep scheduling frames. + * + * Left at the default mdpi, so a dp is a pixel and the ring measures against the design's px + * values directly. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class NavigationBarAvatarScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersYouTabPhotoStates() { + val photo: @Composable (Modifier) -> Unit = { modifier -> + Box(modifier.background(Color(0xFF8E6E5B))) + } + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier + .width(360.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // You unselected, with a photo — the state this test exists for. + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + avatar = photo, + ) + // You selected, with a photo. + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + avatar = photo, + ) + // No photo — the glyph, for contrast. + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + ) + } + } + } + repeat(20) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + // Nearest-neighbour upscale: ring widths are 1-2px at this density, and smoothing them + // would hide exactly what the capture is for. + val scaled = Bitmap.createScaledBitmap(bitmap, width * 3, height * 3, false) + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "nav_bar_you_photo_states.png") + file.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${width}x$height)") + } +}