From 1e6e1d3844b843cd4d8ced996887b67437a0af3a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 18:18:23 -0400 Subject: [PATCH] feat(tabs): badge the Chat tab with unread chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports code-payments/code-ios-app#597. The v2 tab bar now badges the Chat tab with the unread tip-DM conversation count, mirroring the v1 scanner bar's Tips badge — same reactive source (SessionState.tipsUnreadCount), so it appears, updates and clears as conversations are read. - NavigationBarState gains badgeCount(button): Tips (v1) and Chats (v2) are the same tip-DM inbox under the two UIs. - NavigationBarV2 overlays the shared Badge in the brand indicator blue on the glyph's top-right corner, matching the iOS placement. The tab item is no longer clipped so the badge can overhang; safe because its click indication is null, so there is no ripple needing bounds. - AppNavigationBar feeds the count from the session controller. Inherently v2-only: NavigationBarV2 only renders under FeatureFlag.NewUi. --- .../app/internal/ui/AppNavigationBar.kt | 7 +++ .../com/flipcash/app/core/ui/NavigationBar.kt | 46 ++++++++++++++----- 2 files changed, 41 insertions(+), 12 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 35afd328b..c0b36d0c9 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 @@ -67,6 +67,12 @@ internal fun AppNavigationBar( session?.billState?.map { it.bill != null } ?: flowOf(false) }.collectAsStateWithLifecycle(initialValue = false) + // Unread tip-DM count, badged onto the Chat tab — the same source the v1 scanner bar badges its + // Tips button with, so the badge appears, updates and clears as conversations are read. + val tipUnreadCount by remember(session) { + session?.state?.map { it.tipsUnreadCount } ?: flowOf(0) + }.collectAsStateWithLifecycle(initialValue = 0) + Box( modifier = Modifier .then(modifier), @@ -81,6 +87,7 @@ internal fun AppNavigationBar( isNewUi = true, config = NavBarConfig(order = NavBarButton.v2Order), selectedTab = selectedTab ?: NavBarButton.Wallet, + tipUnreadCount = tipUnreadCount, ) NavigationBar( modifier = Modifier 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 62d0c15dd..bcd059a7c 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 @@ -93,7 +93,16 @@ data class NavigationBarState( val showToast: Boolean = false, val toastText: String? = null, val isPaused: Boolean = false, -) +) { + /** + * Unread count to badge [button] with, or 0 for none. Tips and Chats are the same tip-DM + * inbox under the two UIs — v1 surfaces it on the Tips button, v2 on the Chat tab. + */ + fun badgeCount(button: NavBarButton): Int = when (button) { + NavBarButton.Tips, NavBarButton.Chats -> tipUnreadCount + else -> 0 + } +} @Composable fun rememberNavigationBarState( @@ -316,27 +325,40 @@ private fun NavigationBarV2( targetValue = if (selected) 1f else 0.5f, label = "navBarIconAlpha", ) + val badgeCount = state.badgeCount(button) Box( modifier = Modifier .weight(1f) .height(itemHeight) - .clip(CircleShape) - // No ripple — the sliding selection pill is the touch feedback; a Material - // ripple here just competes with it. + // Deliberately unclipped: the unread badge overhangs the icon's top-right + // corner and a clip would shave it. Safe because the click indication is + // null, so there is no ripple that needs bounding. .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { onButtonClick(button) }, contentAlignment = Alignment.Center, ) { - Image( - modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha }, - painter = painterResource(button.icon), - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null, - ) + Box { + 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. + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), + count = badgeCount, + color = CodeTheme.colors.indicator, + ) + } } } }