From 7649ae489e43161f42749c4edbeb944d8dc838c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 18 Jun 2026 15:11:13 -0400 Subject: [PATCH] feat: add unread conversation count to navigation bar badge Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/core/ui/NavigationBar.kt | 95 ++++++++++++++++--- .../main/res/drawable/ic_send_outlined.xml | 18 ++-- .../directsend/internal/SendFlowViewModel.kt | 7 +- .../flipcash/shared/chat/ChatCoordinator.kt | 19 +++- .../sources/ChatMessageDataSource.kt | 2 +- .../session/internal/RealSessionController.kt | 14 ++- .../theme/internal/FlipcashDesignSystem.kt | 2 +- .../kotlin/com/getcode/ui/components/Badge.kt | 6 +- 8 files changed, 127 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 4a1399932..33c4d1b1f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -18,23 +18,37 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.NavBarConfig +import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.theme.xxl @@ -98,7 +112,6 @@ fun NavigationBar( modifier = buttonModifier, label = stringResource(R.string.action_wallet), painter = painterResource(R.drawable.ic_flipcash_balance), - badgeCount = state.notificationUnreadCount, onClick = { onButtonClick(NavBarButton.Wallet) }, toast = { AnimatedVisibility( @@ -131,8 +144,8 @@ fun NavigationBar( NavBarButton.Send -> BottomBarAction( modifier = buttonModifier, label = stringResource(R.string.action_send), + badgeCount = state.notificationUnreadCount, painter = painterResource(R.drawable.ic_send_outlined), - badgeCount = 0, onClick = { onButtonClick(NavBarButton.Send) } ) } @@ -154,7 +167,9 @@ private fun BottomBarAction( onClick: (() -> Unit)?, ) { Column( - modifier = modifier.width(IntrinsicSize.Max), + modifier = modifier + .then(if (badgeCount > 0) Modifier.zIndex(1f) else Modifier) + .width(IntrinsicSize.Max), horizontalAlignment = Alignment.CenterHorizontally, ) { toast() @@ -165,7 +180,6 @@ private fun BottomBarAction( imageSize = imageSize, badge = { Badge( - modifier = Modifier.padding(top = 6.dp, end = 1.dp), count = badgeCount, color = CodeTheme.colors.indicator, enterTransition = scaleIn( @@ -195,6 +209,9 @@ private fun BottomBarAction( badge: @Composable () -> Unit = { }, onClick: (() -> Unit)?, ) { + val maskPadding = 4.dp + var badgeSize by remember { mutableStateOf(IntSize.Zero) } + Layout( modifier = modifier, content = { @@ -209,6 +226,21 @@ private fun BottomBarAction( ) { Image( modifier = Modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val bs = badgeSize + if (bs.width > 0 && bs.height > 0) { + val mp = maskPadding.toPx() + val cpTop = contentPadding.calculateTopPadding().toPx() + drawCircle( + color = Color.Black, + radius = bs.height / 2f + mp, + center = Offset(size.width, cpTop), + blendMode = BlendMode.DstOut, + ) + } + } .padding(contentPadding) .size(imageSize), painter = painter, @@ -222,7 +254,11 @@ private fun BottomBarAction( ) } - Box(modifier = Modifier.layoutId("badge")) { + Box( + modifier = Modifier + .layoutId("badge") + .onSizeChanged { badgeSize = it } + ) { badge() } } @@ -233,17 +269,48 @@ private fun BottomBarAction( val badgePlaceable = measurables.find { it.layoutId == "badge" }?.measure(constraints) - val maxWidth = widthOrZero(actionPlaceable) - val maxHeight = heightOrZero(actionPlaceable) + val badgeWidth = widthOrZero(badgePlaceable) + val badgeHeight = heightOrZero(badgePlaceable) + + val actionWidth = widthOrZero(actionPlaceable) + val actionHeight = heightOrZero(actionPlaceable) + + // Position badge so its left circular end is centered on the icon's top-right corner + val imageSizePx = imageSize.roundToPx() + val iconTop = contentPadding.calculateTopPadding().roundToPx() + val iconRight = (actionWidth + imageSizePx) / 2 + val badgeX = iconRight - badgeHeight / 2 + val badgeY = iconTop - badgeHeight / 2 + layout( - width = maxWidth, - height = maxHeight, + width = actionWidth, + height = actionHeight, ) { actionPlaceable?.placeRelative(0, 0) - badgePlaceable?.placeRelative( - x = maxWidth - widthOrZero(badgePlaceable), - y = -(heightOrZero(badgePlaceable) / 3) - ) + badgePlaceable?.placeRelativeWithLayer(x = badgeX, y = badgeY) { + clip = false + } } } } + + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarPreview() { + NavigationBar( + state = NavigationBarState(notificationUnreadCount = 100), + ) +} +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun SendActionPreview() { + BottomBarAction( + painter = painterResource(R.drawable.ic_send_outlined), + label = "Send", + badgeCount = 100, + onClick = null, + ) +} diff --git a/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml b/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml index 89cf680bc..4c67f2ec9 100644 --- a/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml +++ b/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml @@ -1,17 +1,13 @@ + android:width="40dp" + android:height="40dp" + android:viewportWidth="40" + android:viewportHeight="40"> + android:pathData="M0,0h40v40h-40z"/> + android:pathData="M20,35L18.395,35.45L19.452,39.218L21.436,35.845L20,35ZM36.667,6.667L38.103,7.512L39.581,5H36.667V6.667ZM4.167,6.667V5H0.289L2.957,7.814L4.167,6.667ZM15.363,18.473L16.968,18.023L16.857,17.626L16.573,17.326L15.363,18.473ZM35.808,9.048L37.266,8.24L35.65,5.325L34.192,6.133L35.808,9.048ZM21.436,35.845L38.103,7.512L35.23,5.822L18.563,34.155L21.436,35.845ZM36.667,5H4.167V8.333H36.667V5ZM2.957,7.814L14.154,19.62L16.573,17.326L5.376,5.52L2.957,7.814ZM13.759,18.924L18.395,35.45L21.605,34.55L16.968,18.023L13.759,18.924ZM16.841,19.56L35.808,9.048L34.192,6.133L15.225,16.644L16.841,19.56Z" + android:fillColor="#ffffff"/> diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 6f9157546..6d4a23731 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -110,7 +110,10 @@ internal class SendFlowViewModel @Inject constructor( combine( contactCoordinator.state, - stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } }, + stateFlow + .map { it.searchState } + .distinctUntilChanged() + .flatMapLatest { snapshotFlow { it.text } }, chatCoordinator.feed, tokenCoordinator.tokens, ) { contactState, searchText, chatFeed, tokens -> @@ -261,7 +264,7 @@ internal class SendFlowViewModel @Inject constructor( val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } ?: formattedPhone - ?: return@mapNotNull null // filter out anonymous chats + ?: return@mapNotNull null val unknown = DeviceContact.unknownContact( e164 = phone.orEmpty(), diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 5c4d8c74e..517d45d0e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -104,14 +104,23 @@ class ChatCoordinator @Inject constructor( val feed: Flow> get() = _state.map { state -> - state.feed.map { metadata -> + val selfId = userManager.accountId + state.feed.mapNotNull { metadata -> + // Filter out anonymous chats (DMs where the other member has no name or phone) + val otherMember = metadata.members.firstOrNull { it.userId != selfId } + if (otherMember != null) { + val profile = otherMember.userProfile + val hasIdentity = !profile.displayName.isNullOrBlank() || + !profile.verifiedPhoneNumber.isNullOrBlank() + if (!hasIdentity) return@mapNotNull null + } + val readPointer = metadata.members - .firstOrNull { it.userId == userManager.accountId } + .firstOrNull { it.userId == selfId } ?.pointers ?.firstOrNull { it.type == PointerType.READ } ?.value ?: 0L - val selfId = userManager.accountId val unreadCount = metadata.lastMessage?.let { lastMsg -> if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 } ?: 0 @@ -189,6 +198,10 @@ class ChatCoordinator @Inject constructor( return runCatching { ChatId(raw.decodeBase58()) } } + fun observeUnreadConversations(): Flow { + return feed.map { summaries -> summaries.count { it.unreadCount > 0 } } + } + fun observeMessages(chatId: ChatId): Flow> { return messageDataSource.observeMessages(chatId) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 3ea767450..ec690e5a1 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -116,7 +116,7 @@ class ChatMessageDataSource @Inject constructor( && entity.senderIdHex == selfHex && entity.pendingClientIdHex == null ) { - entity.copy(pendingClientIdHex = rescuedIds.removeFirst()) + entity.copy(pendingClientIdHex = rescuedIds.removeAt(0)) } else entity } } else entities diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index df9f70273..27a2e6076 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -76,6 +76,9 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -183,13 +186,22 @@ class RealSessionController @Inject constructor( .launchIn(scope) userManager.state - .mapNotNull { it.authState } + .map { it.authState } .filter { it.isAtLeastRegistered } .distinctUntilChanged() .filter { userManager.state.value.flags?.requiresIapForRegistration == true } .onEach { billingClient.connect() } .launchIn(scope) + userManager.state + .map { it.authState } + .filter { it.isAtLeastRegistered } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeUnreadConversations() } + .distinctUntilChanged() + .onEach { count -> _state.update { it.copy(notificationUnreadCount = count) } } + .launchIn(scope) + appSettingsCoordinator .observeValue(AppSettingValue.CameraStartByDefault) .onEach { autoStart -> _state.update { it.copy(autoStartCamera = autoStart) } } diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index 98507b987..355b75e12 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -33,7 +33,7 @@ object Flipcash2ColorSpec { val secondary = Color(115, 129, 121) val secondaryText = Color.White.copy(alpha = 0.5f) val cashBill = Color(0xFF06450F) - val notification = Color(0xFF009EE7) + val notification = Color(0xFF058AFF) val trackColor = Color.White.copy(alpha = 0.07f) val bannerThemed = Color(0xFF252526) val success = Color(0xFF1AC86A) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt index d105c6e28..5db18d063 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt @@ -25,10 +25,10 @@ import com.getcode.theme.CodeTheme fun Badge( count: Int, modifier: Modifier = Modifier, - showMoreUnread: Boolean = count > 99, + showMoreUnread: Boolean = count > 100, color: Color = CodeTheme.colors.brand, contentColor: Color = Color.White, - textStyle: TextStyle = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W700), + textStyle: TextStyle = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), enterTransition: EnterTransition = scaleIn(tween(durationMillis = 300)) + fadeIn(), exitTransition: ExitTransition = fadeOut() + scaleOut(tween(durationMillis = 300)) ) { @@ -49,7 +49,7 @@ fun Badge( contentColor = contentColor, contentPadding = PaddingValues( horizontal = CodeTheme.dimens.grid.x1, - vertical = 0.dp + vertical = 3.dp ) ) { Text(