A cheap attempt at a native Bluesky client for Android

Notifications: Show unread count in bottom bar

Add a badge to the "Notifications" icon in the bottom navigation bar to display the number of unread notifications.

This involved:
* Updating the `TimelineViewModel` to count unread notifications and expose it in the `UiState`.
* Using `BadgedBox` in `MainView` to display the count on the navigation item.
* Adding accessibility content descriptions for the notification badge (e.g., "1 new notification").

+68 -6
+58 -6
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 27 27 import androidx.compose.material.icons.filled.Home 28 28 import androidx.compose.material.icons.filled.Notifications 29 29 import androidx.compose.material.icons.filled.Tag 30 + import androidx.compose.material3.Badge 31 + import androidx.compose.material3.BadgedBox 30 32 import androidx.compose.material3.BottomSheetScaffold 31 33 import androidx.compose.material3.DrawerValue 32 34 import androidx.compose.material3.ExperimentalMaterial3Api ··· 55 57 import androidx.compose.material3.rememberTopAppBarState 56 58 import androidx.compose.runtime.Composable 57 59 import androidx.compose.runtime.LaunchedEffect 60 + import androidx.compose.runtime.MutableIntState 58 61 import androidx.compose.runtime.getValue 62 + import androidx.compose.runtime.mutableIntStateOf 59 63 import androidx.compose.runtime.mutableStateOf 60 64 import androidx.compose.runtime.remember 61 65 import androidx.compose.runtime.saveable.rememberSaveable ··· 68 72 import androidx.compose.ui.input.nestedscroll.nestedScroll 69 73 import androidx.compose.ui.platform.LocalContext 70 74 import androidx.compose.ui.res.stringResource 75 + import androidx.compose.ui.semantics.contentDescription 76 + import androidx.compose.ui.semantics.semantics 71 77 import androidx.compose.ui.text.font.FontWeight 72 78 import androidx.compose.ui.unit.dp 73 79 import coil3.compose.AsyncImage ··· 82 88 enum class TabBarDestinations( 83 89 @param:StringRes val label: Int, 84 90 val icon: ImageVector, 85 - @param:StringRes val contentDescription: Int 91 + @param:StringRes val contentDescription: Int, 92 + val badgeValue: MutableIntState? = null, 93 + val badgeDescFmt: (Int) -> String = { "" }, 86 94 ) { 87 95 TIMELINE(R.string.timeline, Icons.Filled.Home, R.string.timeline), 88 - NOTIFICATIONS(R.string.notifications, Icons.Filled.Notifications, R.string.notifications) 96 + NOTIFICATIONS( 97 + R.string.notifications, 98 + Icons.Filled.Notifications, 99 + R.string.notifications, 100 + mutableIntStateOf(0), 101 + badgeDescFmt = { notifAmt -> 102 + when (notifAmt) { 103 + 0 -> "No new notifications" 104 + 1 -> "1 new notification" 105 + else -> "$notifAmt new notifications" 106 + } 107 + } 108 + ) 89 109 } 90 110 91 111 ··· 258 278 ) 259 279 } 260 280 ) { 281 + LaunchedEffect(timelineViewModel.uiState.unreadNotificationsAmt) { 282 + TabBarDestinations.NOTIFICATIONS.badgeValue?.intValue = 283 + timelineViewModel.uiState.unreadNotificationsAmt 284 + } 285 + 261 286 Scaffold( 262 287 containerColor = MaterialTheme.colorScheme.background, 263 288 modifier = Modifier ··· 409 434 TabBarDestinations.entries.forEach { 410 435 NavigationBarItem( 411 436 icon = { 412 - Icon( 413 - it.icon, 414 - contentDescription = stringResource(it.contentDescription) 415 - ) 437 + if (it.badgeValue != null) { 438 + val badgeValue = remember { it.badgeValue } 439 + BadgedBox( 440 + badge = { 441 + if (badgeValue.intValue == 0) { 442 + return@BadgedBox 443 + } 444 + 445 + Badge { 446 + Text( 447 + badgeValue.intValue.toString(), 448 + modifier = 449 + Modifier.semantics { 450 + contentDescription = 451 + it.badgeDescFmt(it.badgeValue.intValue) 452 + }, 453 + ) 454 + } 455 + } 456 + ) { 457 + Icon( 458 + it.icon, 459 + contentDescription = stringResource(it.contentDescription) 460 + ) 461 + } 462 + } else { 463 + Icon( 464 + it.icon, 465 + contentDescription = stringResource(it.contentDescription) 466 + ) 467 + } 416 468 }, 417 469 label = { Text(stringResource(it.label)) }, 418 470 selected = it == currentDestination,
+10
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 53 53 54 54 val timelineCursor: String? = null, 55 55 val notificationsCursor: String? = null, 56 + val unreadNotificationsAmt: Int = 0, 56 57 57 58 val cidInteractedWith: Map<Cid, RKey> = mapOf(), 58 59 ··· 204 205 if (rawNotifs == null) { 205 206 return@launch 206 207 } 208 + 209 + uiState = uiState.copy( 210 + unreadNotificationsAmt = rawNotifs.notifications.fold(0) { acc, notification -> 211 + when (notification.isRead) { 212 + false -> acc + 1 213 + true -> acc 214 + } 215 + } 216 + ) 207 217 208 218 val repeatable = mutableListOf<Notification>() 209 219