Skip to content

Commit 9024a7d

Browse files
authored
feat: add unread conversation count to navigation bar badge (#940)
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 911090f commit 9024a7d

8 files changed

Lines changed: 127 additions & 36 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,37 @@ import androidx.compose.foundation.layout.fillMaxWidth
1818
import androidx.compose.foundation.layout.padding
1919
import androidx.compose.foundation.layout.size
2020
import androidx.compose.foundation.layout.width
21-
import androidx.compose.material.Text
21+
import androidx.compose.material3.Text
2222
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableStateOf
2325
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.setValue
2427
import androidx.compose.ui.Alignment
2528
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.draw.drawWithContent
30+
import androidx.compose.ui.geometry.Offset
31+
import androidx.compose.ui.graphics.BlendMode
2632
import androidx.compose.ui.graphics.Color
2733
import androidx.compose.ui.graphics.ColorFilter
34+
import androidx.compose.ui.graphics.CompositingStrategy
35+
import androidx.compose.ui.graphics.graphicsLayer
2836
import androidx.compose.ui.graphics.painter.Painter
2937
import androidx.compose.ui.layout.Layout
3038
import androidx.compose.ui.layout.layoutId
39+
import androidx.compose.ui.layout.onSizeChanged
3140
import androidx.compose.ui.res.painterResource
3241
import androidx.compose.ui.res.stringResource
3342
import androidx.compose.ui.text.font.FontWeight
43+
import androidx.compose.ui.tooling.preview.Preview
44+
import androidx.compose.ui.tooling.preview.PreviewWrapper
3445
import androidx.compose.ui.unit.Dp
46+
import androidx.compose.ui.unit.IntSize
3547
import androidx.compose.ui.unit.dp
48+
import androidx.compose.ui.zIndex
3649
import com.flipcash.app.core.navigation.NavBarButton
3750
import com.flipcash.app.core.navigation.NavBarConfig
51+
import com.flipcash.app.theme.FlipcashThemeWrapper
3852
import com.flipcash.core.R
3953
import com.getcode.theme.CodeTheme
4054
import com.getcode.theme.xxl
@@ -98,7 +112,6 @@ fun NavigationBar(
98112
modifier = buttonModifier,
99113
label = stringResource(R.string.action_wallet),
100114
painter = painterResource(R.drawable.ic_flipcash_balance),
101-
badgeCount = state.notificationUnreadCount,
102115
onClick = { onButtonClick(NavBarButton.Wallet) },
103116
toast = {
104117
AnimatedVisibility(
@@ -131,8 +144,8 @@ fun NavigationBar(
131144
NavBarButton.Send -> BottomBarAction(
132145
modifier = buttonModifier,
133146
label = stringResource(R.string.action_send),
147+
badgeCount = state.notificationUnreadCount,
134148
painter = painterResource(R.drawable.ic_send_outlined),
135-
badgeCount = 0,
136149
onClick = { onButtonClick(NavBarButton.Send) }
137150
)
138151
}
@@ -154,7 +167,9 @@ private fun BottomBarAction(
154167
onClick: (() -> Unit)?,
155168
) {
156169
Column(
157-
modifier = modifier.width(IntrinsicSize.Max),
170+
modifier = modifier
171+
.then(if (badgeCount > 0) Modifier.zIndex(1f) else Modifier)
172+
.width(IntrinsicSize.Max),
158173
horizontalAlignment = Alignment.CenterHorizontally,
159174
) {
160175
toast()
@@ -165,7 +180,6 @@ private fun BottomBarAction(
165180
imageSize = imageSize,
166181
badge = {
167182
Badge(
168-
modifier = Modifier.padding(top = 6.dp, end = 1.dp),
169183
count = badgeCount,
170184
color = CodeTheme.colors.indicator,
171185
enterTransition = scaleIn(
@@ -195,6 +209,9 @@ private fun BottomBarAction(
195209
badge: @Composable () -> Unit = { },
196210
onClick: (() -> Unit)?,
197211
) {
212+
val maskPadding = 4.dp
213+
var badgeSize by remember { mutableStateOf(IntSize.Zero) }
214+
198215
Layout(
199216
modifier = modifier,
200217
content = {
@@ -209,6 +226,21 @@ private fun BottomBarAction(
209226
) {
210227
Image(
211228
modifier = Modifier
229+
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
230+
.drawWithContent {
231+
drawContent()
232+
val bs = badgeSize
233+
if (bs.width > 0 && bs.height > 0) {
234+
val mp = maskPadding.toPx()
235+
val cpTop = contentPadding.calculateTopPadding().toPx()
236+
drawCircle(
237+
color = Color.Black,
238+
radius = bs.height / 2f + mp,
239+
center = Offset(size.width, cpTop),
240+
blendMode = BlendMode.DstOut,
241+
)
242+
}
243+
}
212244
.padding(contentPadding)
213245
.size(imageSize),
214246
painter = painter,
@@ -222,7 +254,11 @@ private fun BottomBarAction(
222254
)
223255
}
224256

225-
Box(modifier = Modifier.layoutId("badge")) {
257+
Box(
258+
modifier = Modifier
259+
.layoutId("badge")
260+
.onSizeChanged { badgeSize = it }
261+
) {
226262
badge()
227263
}
228264
}
@@ -233,17 +269,48 @@ private fun BottomBarAction(
233269
val badgePlaceable =
234270
measurables.find { it.layoutId == "badge" }?.measure(constraints)
235271

236-
val maxWidth = widthOrZero(actionPlaceable)
237-
val maxHeight = heightOrZero(actionPlaceable)
272+
val badgeWidth = widthOrZero(badgePlaceable)
273+
val badgeHeight = heightOrZero(badgePlaceable)
274+
275+
val actionWidth = widthOrZero(actionPlaceable)
276+
val actionHeight = heightOrZero(actionPlaceable)
277+
278+
// Position badge so its left circular end is centered on the icon's top-right corner
279+
val imageSizePx = imageSize.roundToPx()
280+
val iconTop = contentPadding.calculateTopPadding().roundToPx()
281+
val iconRight = (actionWidth + imageSizePx) / 2
282+
val badgeX = iconRight - badgeHeight / 2
283+
val badgeY = iconTop - badgeHeight / 2
284+
238285
layout(
239-
width = maxWidth,
240-
height = maxHeight,
286+
width = actionWidth,
287+
height = actionHeight,
241288
) {
242289
actionPlaceable?.placeRelative(0, 0)
243-
badgePlaceable?.placeRelative(
244-
x = maxWidth - widthOrZero(badgePlaceable),
245-
y = -(heightOrZero(badgePlaceable) / 3)
246-
)
290+
badgePlaceable?.placeRelativeWithLayer(x = badgeX, y = badgeY) {
291+
clip = false
292+
}
247293
}
248294
}
249295
}
296+
297+
298+
@Preview
299+
@PreviewWrapper(FlipcashThemeWrapper::class)
300+
@Composable
301+
private fun NavigationBarPreview() {
302+
NavigationBar(
303+
state = NavigationBarState(notificationUnreadCount = 100),
304+
)
305+
}
306+
@Preview
307+
@PreviewWrapper(FlipcashThemeWrapper::class)
308+
@Composable
309+
private fun SendActionPreview() {
310+
BottomBarAction(
311+
painter = painterResource(R.drawable.ic_send_outlined),
312+
label = "Send",
313+
badgeCount = 100,
314+
onClick = null,
315+
)
316+
}
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2-
android:width="36dp"
3-
android:height="36dp"
4-
android:viewportWidth="36"
5-
android:viewportHeight="36">
2+
android:width="40dp"
3+
android:height="40dp"
4+
android:viewportWidth="40"
5+
android:viewportHeight="40">
66
<group>
77
<clip-path
8-
android:pathData="M0,0h36v36h-36z"/>
8+
android:pathData="M0,0h40v40h-40z"/>
99
<path
10-
android:pathData="M14.607,20.95L4,16L28.395,7.161L19.556,31.556L14.607,20.95ZM14.607,20.95L18.496,17.061"
11-
android:strokeLineJoin="round"
12-
android:strokeWidth="2.5"
13-
android:fillColor="#00000000"
14-
android:strokeColor="#ffffff"
15-
android:strokeLineCap="round"/>
10+
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"
11+
android:fillColor="#ffffff"/>
1612
</group>
1713
</vector>

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ internal class SendFlowViewModel @Inject constructor(
110110

111111
combine(
112112
contactCoordinator.state,
113-
stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } },
113+
stateFlow
114+
.map { it.searchState }
115+
.distinctUntilChanged()
116+
.flatMapLatest { snapshotFlow { it.text } },
114117
chatCoordinator.feed,
115118
tokenCoordinator.tokens,
116119
) { contactState, searchText, chatFeed, tokens ->
@@ -261,7 +264,7 @@ internal class SendFlowViewModel @Inject constructor(
261264
val formattedPhone = phone?.let { phoneUtils.formatNumber(it) }
262265
val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() }
263266
?: formattedPhone
264-
?: return@mapNotNull null // filter out anonymous chats
267+
?: return@mapNotNull null
265268

266269
val unknown = DeviceContact.unknownContact(
267270
e164 = phone.orEmpty(),

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,23 @@ class ChatCoordinator @Inject constructor(
104104

105105
val feed: Flow<List<ChatSummary>>
106106
get() = _state.map { state ->
107-
state.feed.map { metadata ->
107+
val selfId = userManager.accountId
108+
state.feed.mapNotNull { metadata ->
109+
// Filter out anonymous chats (DMs where the other member has no name or phone)
110+
val otherMember = metadata.members.firstOrNull { it.userId != selfId }
111+
if (otherMember != null) {
112+
val profile = otherMember.userProfile
113+
val hasIdentity = !profile.displayName.isNullOrBlank() ||
114+
!profile.verifiedPhoneNumber.isNullOrBlank()
115+
if (!hasIdentity) return@mapNotNull null
116+
}
117+
108118
val readPointer = metadata.members
109-
.firstOrNull { it.userId == userManager.accountId }
119+
.firstOrNull { it.userId == selfId }
110120
?.pointers
111121
?.firstOrNull { it.type == PointerType.READ }
112122
?.value ?: 0L
113123

114-
val selfId = userManager.accountId
115124
val unreadCount = metadata.lastMessage?.let { lastMsg ->
116125
if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0
117126
} ?: 0
@@ -189,6 +198,10 @@ class ChatCoordinator @Inject constructor(
189198
return runCatching { ChatId(raw.decodeBase58()) }
190199
}
191200

201+
fun observeUnreadConversations(): Flow<Int> {
202+
return feed.map { summaries -> summaries.count { it.unreadCount > 0 } }
203+
}
204+
192205
fun observeMessages(chatId: ChatId): Flow<List<ChatMessage>> {
193206
return messageDataSource.observeMessages(chatId)
194207
}

apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ class ChatMessageDataSource @Inject constructor(
116116
&& entity.senderIdHex == selfHex
117117
&& entity.pendingClientIdHex == null
118118
) {
119-
entity.copy(pendingClientIdHex = rescuedIds.removeFirst())
119+
entity.copy(pendingClientIdHex = rescuedIds.removeAt(0))
120120
} else entity
121121
}
122122
} else entities

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ import kotlinx.coroutines.flow.filter
7676
import kotlinx.coroutines.flow.first
7777
import kotlinx.coroutines.flow.launchIn
7878
import kotlinx.coroutines.flow.map
79+
import kotlinx.coroutines.flow.flatMapLatest
80+
import kotlinx.coroutines.flow.flowOf
81+
import kotlinx.coroutines.flow.map
7982
import kotlinx.coroutines.flow.mapNotNull
8083
import kotlinx.coroutines.flow.onEach
8184
import kotlinx.coroutines.flow.update
@@ -183,13 +186,22 @@ class RealSessionController @Inject constructor(
183186
.launchIn(scope)
184187

185188
userManager.state
186-
.mapNotNull { it.authState }
189+
.map { it.authState }
187190
.filter { it.isAtLeastRegistered }
188191
.distinctUntilChanged()
189192
.filter { userManager.state.value.flags?.requiresIapForRegistration == true }
190193
.onEach { billingClient.connect() }
191194
.launchIn(scope)
192195

196+
userManager.state
197+
.map { it.authState }
198+
.filter { it.isAtLeastRegistered }
199+
.distinctUntilChanged()
200+
.flatMapLatest { chatCoordinator.observeUnreadConversations() }
201+
.distinctUntilChanged()
202+
.onEach { count -> _state.update { it.copy(notificationUnreadCount = count) } }
203+
.launchIn(scope)
204+
193205
appSettingsCoordinator
194206
.observeValue(AppSettingValue.CameraStartByDefault)
195207
.onEach { autoStart -> _state.update { it.copy(autoStartCamera = autoStart) } }

apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ object Flipcash2ColorSpec {
3333
val secondary = Color(115, 129, 121)
3434
val secondaryText = Color.White.copy(alpha = 0.5f)
3535
val cashBill = Color(0xFF06450F)
36-
val notification = Color(0xFF009EE7)
36+
val notification = Color(0xFF058AFF)
3737
val trackColor = Color.White.copy(alpha = 0.07f)
3838
val bannerThemed = Color(0xFF252526)
3939
val success = Color(0xFF1AC86A)

ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import com.getcode.theme.CodeTheme
2525
fun Badge(
2626
count: Int,
2727
modifier: Modifier = Modifier,
28-
showMoreUnread: Boolean = count > 99,
28+
showMoreUnread: Boolean = count > 100,
2929
color: Color = CodeTheme.colors.brand,
3030
contentColor: Color = Color.White,
31-
textStyle: TextStyle = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W700),
31+
textStyle: TextStyle = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold),
3232
enterTransition: EnterTransition = scaleIn(tween(durationMillis = 300)) + fadeIn(),
3333
exitTransition: ExitTransition = fadeOut() + scaleOut(tween(durationMillis = 300))
3434
) {
@@ -49,7 +49,7 @@ fun Badge(
4949
contentColor = contentColor,
5050
contentPadding = PaddingValues(
5151
horizontal = CodeTheme.dimens.grid.x1,
52-
vertical = 0.dp
52+
vertical = 3.dp
5353
)
5454
) {
5555
Text(

0 commit comments

Comments
 (0)