From 4ac770e1333658576edfa6f2007040b77e247212 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 17:01:37 -0400 Subject: [PATCH 1/2] feat(navbar): show the profile photo on the You tab Node 9641:17130 replaces the You tab's glyph with the account's own avatar once a photo is set: a circle inset in the icon slot, ringed in white, fading with the tab like the other glyphs. NavigationBar lives in core-ui, below the profile layer, so it takes the avatar as a slot and keeps the glyph when the caller passes null. AppNavigationBar reads the picture from UserManager, gated on Ready for the same reason as the rest of the profile-driven chrome: a named account restores its cached profile before auth completes, so an ungated read would show the previous account's avatar on the way in. --- apps/flipcash/app/build.gradle.kts | 1 + .../app/internal/ui/AppNavigationBar.kt | 25 +++++++++++ .../com/flipcash/app/core/ui/NavigationBar.kt | 44 +++++++++++++++---- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index e2866fc80..bcdc315f7 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -198,6 +198,7 @@ dependencies { implementation(project(":apps:flipcash:shared:region-selection:core")) implementation(project(":apps:flipcash:shared:region-selection:ui")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:notifications")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index febe7dd96..80b63e5c9 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.LocalUserManager import dev.chrisbanes.haze.HazeState import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.asNavBarTab @@ -24,9 +25,13 @@ import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.NavigationBar import com.flipcash.app.core.ui.rememberNavigationBarState import com.flipcash.app.session.LocalSessionController +import com.flipcash.services.user.AuthState +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -72,6 +77,25 @@ internal fun AppNavigationBar( session?.state?.map { it.tipsUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) + // The You tab wears the account's own photo once one is set. Gated on Ready like the rest of + // the profile-driven chrome: a named account restores its cached profile before auth + // completes, so an ungated read would show the previous account's avatar on the way in. + val userManager = LocalUserManager.current + val profile by remember(userManager) { + userManager?.state + ?.filter { it.authState is AuthState.Ready } + ?.map { it.userProfile } + ?.distinctUntilChanged() + ?: flowOf(null) + }.collectAsStateWithLifecycle(initialValue = null) + val profilePicture = profile?.profilePicture + val displayName = profile?.displayName.orEmpty() + val avatar: (@Composable (Modifier) -> Unit)? = if (profilePicture != null) { + { avatarModifier -> ContactAvatar(profilePicture, displayName, avatarModifier) } + } else { + null + } + Box( modifier = Modifier .then(modifier), @@ -97,6 +121,7 @@ internal fun AppNavigationBar( navigator.replaceAll(button.destinationRoute()) }, hazeState = hazeState, + avatar = avatar, ) } } 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 2379e721a..ee86667fa 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -87,6 +88,11 @@ 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. + avatar: (@Composable (Modifier) -> Unit)? = null, ) { val order = NavBarButton.tabs if (order.isEmpty()) return @@ -174,14 +180,36 @@ fun NavigationBar( contentAlignment = Alignment.Center, ) { Box { - Image( - modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha }, - painter = painterResource(button.icon), - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null, - ) + if (button == NavBarButton.TipCard && avatar != null) { + Box( + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = iconAlpha } + .padding(CodeTheme.dimens.thickBorder), + ) { + avatar(Modifier.fillMaxSize().clip(CircleShape)) + // Drawn over the photo rather than behind it, so the ring survives + // whatever background the avatar paints for itself. + Box( + modifier = Modifier + .fillMaxSize() + .border( + CodeTheme.dimens.thickBorder, + Color.White, + CircleShape, + ), + ) + } + } else { + Image( + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = iconAlpha }, + painter = painterResource(button.icon), + colorFilter = ColorFilter.tint(Color.White), + 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. From f5cc24e07026ad726b4fd63db6819597f582cfc9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 17:11:18 -0400 Subject: [PATCH 2/2] refactor(navbar): name the You tab avatar lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile read, its Ready gate and the slot it produces are one idea; giving them a name keeps AppNavigationBar's body to the three flows it reads. The distinctUntilChanged goes with it — collectAsStateWithLifecycle already skips an equal value. --- .../app/internal/ui/AppNavigationBar.kt | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 80b63e5c9..d299271fe 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -30,7 +30,6 @@ import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -77,24 +76,7 @@ internal fun AppNavigationBar( session?.state?.map { it.tipsUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) - // The You tab wears the account's own photo once one is set. Gated on Ready like the rest of - // the profile-driven chrome: a named account restores its cached profile before auth - // completes, so an ungated read would show the previous account's avatar on the way in. - val userManager = LocalUserManager.current - val profile by remember(userManager) { - userManager?.state - ?.filter { it.authState is AuthState.Ready } - ?.map { it.userProfile } - ?.distinctUntilChanged() - ?: flowOf(null) - }.collectAsStateWithLifecycle(initialValue = null) - val profilePicture = profile?.profilePicture - val displayName = profile?.displayName.orEmpty() - val avatar: (@Composable (Modifier) -> Unit)? = if (profilePicture != null) { - { avatarModifier -> ContactAvatar(profilePicture, displayName, avatarModifier) } - } else { - null - } + val avatar = rememberProfileAvatar() Box( modifier = Modifier @@ -126,3 +108,25 @@ internal fun AppNavigationBar( } } } + +/** + * The account's own photo, ready to drop into the You tab, or null when there isn't one. + * + * Gated on Ready like the rest of the profile-driven chrome: a named account restores its cached + * profile before auth completes, so an ungated read would show the previous account's avatar on + * the way in. + */ +@Composable +private fun rememberProfileAvatar(): (@Composable (Modifier) -> Unit)? { + val userManager = LocalUserManager.current + val profile by remember(userManager) { + userManager?.state + ?.filter { it.authState is AuthState.Ready } + ?.map { it.userProfile } + ?: flowOf(null) + }.collectAsStateWithLifecycle(initialValue = null) + + val picture = profile?.profilePicture ?: return null + val displayName = profile?.displayName.orEmpty() + return { modifier -> ContactAvatar(picture, displayName, modifier) } +}