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 828968f23..df0f2764b 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 000000000..604f65ee9 --- /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)") + } +}