diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/badge/NotificationBadge.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/badge/NotificationBadge.kt new file mode 100644 index 0000000000..fc0aaee182 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/badge/NotificationBadge.kt @@ -0,0 +1,73 @@ +package com.x8bit.bitwarden.ui.platform.components.badge + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Badge +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors + +/** + * Reusable component for displaying a notification badge. + * + * @param notificationCount numeric value to display in center of the badge. + */ +@Composable +fun NotificationBadge( + notificationCount: Int, + modifier: Modifier = Modifier, + isVisible: Boolean = true, + backgroundColor: Color = LocalNonMaterialColors.current.fingerprint, + contentColor: Color = MaterialTheme.colorScheme.onSecondary, +) { + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + Badge( + content = { + Text( + text = notificationCount.toString(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + ) + }, + modifier = modifier, + containerColor = backgroundColor, + contentColor = contentColor, + ) + } +} + +@Preview +@Composable +private fun NotificationBadge_preview() { + Column( + modifier = Modifier + .background(color = Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + NotificationBadge(notificationCount = 0, backgroundColor = Color.Red) + NotificationBadge(notificationCount = 4, backgroundColor = Color.Red) + NotificationBadge(notificationCount = 199, backgroundColor = Color.Green) + NotificationBadge( + notificationCount = 1999, + backgroundColor = Color.Blue, + contentColor = Color.Yellow, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index d181a431e4..efbb010dab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets @@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.BadgedBox import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -44,6 +47,7 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.max import com.x8bit.bitwarden.ui.platform.base.util.toDp +import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scrim.BitwardenAnimatedScrim import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter @@ -267,7 +271,7 @@ private fun VaultBottomAppBar( ), VaultUnlockedNavBarTab.Send, VaultUnlockedNavBarTab.Generator, - VaultUnlockedNavBarTab.Settings, + VaultUnlockedNavBarTab.Settings(state.notificationState.settingsTabNotificationCount), ) // Collecting the back stack entry here as state is crucial to ensuring that the items // below recompose when the navigation state changes to update the selected tab. @@ -277,18 +281,27 @@ private fun VaultBottomAppBar( NavigationBarItem( icon = { - Icon( - painter = rememberVectorPainter( - id = if (isSelected) { - destination.iconResSelected - } else { - destination.iconRes - }, - ), - contentDescription = stringResource( - id = destination.contentDescriptionRes, - ), - ) + BadgedBox( + badge = { + NotificationBadge( + notificationCount = destination.notificationCount, + isVisible = destination.notificationCount > 0, + ) + }, + ) { + Icon( + painter = rememberVectorPainter( + id = if (isSelected) { + destination.iconResSelected + } else { + destination.iconRes + }, + ), + contentDescription = stringResource( + id = destination.contentDescriptionRes, + ), + ) + } }, label = { Text( @@ -303,7 +316,7 @@ private fun VaultBottomAppBar( is VaultUnlockedNavBarTab.Vault -> vaultTabClickedAction() VaultUnlockedNavBarTab.Send -> sendTabClickedAction() VaultUnlockedNavBarTab.Generator -> generatorTabClickedAction() - VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction() + is VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction() } }, colors = NavigationBarItemDefaults.colors( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt index 1e544170a7..fc6aa30e2c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab @@ -23,10 +24,14 @@ import javax.inject.Inject class VaultUnlockedNavBarViewModel @Inject constructor( authRepository: AuthRepository, specialCircumstancesManager: SpecialCircumstanceManager, + settingsRepository: SettingsRepository, ) : BaseViewModel( initialState = VaultUnlockedNavBarState( vaultNavBarLabelRes = R.string.my_vault, vaultNavBarContentDescriptionRes = R.string.my_vault, + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = settingsRepository.allSettingsBadgeCountFlow.value, + ), ), ) { init { @@ -37,6 +42,13 @@ class VaultUnlockedNavBarViewModel @Inject constructor( } .launchIn(viewModelScope) + settingsRepository + .allSettingsBadgeCountFlow + .onEach { + sendAction(VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate(it)) + } + .launchIn(viewModelScope) + when (specialCircumstancesManager.specialCircumstance) { SpecialCircumstance.GeneratorShortcut -> { sendEvent(VaultUnlockedNavBarEvent.Shortcut.NavigateToGeneratorScreen) @@ -72,6 +84,10 @@ class VaultUnlockedNavBarViewModel @Inject constructor( is VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive -> { handleUserStateUpdateReceive(action) } + + is VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate -> { + handleSettingsNotificationCountUpdate(action) + } } } // #region BottomTabViewModel Action Handlers @@ -128,6 +144,18 @@ class VaultUnlockedNavBarViewModel @Inject constructor( ) } } + + private fun handleSettingsNotificationCountUpdate( + action: VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate, + ) { + mutableStateFlow.update { + it.copy( + notificationState = it.notificationState.copy( + settingsTabNotificationCount = action.count, + ), + ) + } + } // #endregion BottomTabViewModel Action Handlers } @@ -137,6 +165,14 @@ class VaultUnlockedNavBarViewModel @Inject constructor( data class VaultUnlockedNavBarState( @StringRes val vaultNavBarLabelRes: Int, @StringRes val vaultNavBarContentDescriptionRes: Int, + val notificationState: VaultUnlockedNavBarNotificationState, +) + +/** + * Models the notification state for each the tabs in the nav bar which support notification badges. + */ +data class VaultUnlockedNavBarNotificationState( + val settingsTabNotificationCount: Int, ) /** @@ -170,9 +206,12 @@ sealed class VaultUnlockedNavBarAction { /** * Indicates a change in user state has been received. */ - data class UserStateUpdateReceive( - val userState: UserState?, - ) : Internal() + data class UserStateUpdateReceive(val userState: UserState?) : Internal() + + /** + * Indicates a change to the count of settings notifications to show + */ + data class SettingsNotificationCountUpdate(val count: Int) : Internal() } } @@ -217,7 +256,7 @@ sealed class VaultUnlockedNavBarEvent { * Navigate to the Settings screen. */ data object NavigateToSettingsScreen : VaultUnlockedNavBarEvent() { - override val tab: VaultUnlockedNavBarTab = VaultUnlockedNavBarTab.Settings + override val tab: VaultUnlockedNavBarTab = VaultUnlockedNavBarTab.Settings() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/model/VaultUnlockedNavBarTab.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/model/VaultUnlockedNavBarTab.kt index 401a2aaa71..f6a243aad5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/model/VaultUnlockedNavBarTab.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/model/VaultUnlockedNavBarTab.kt @@ -49,6 +49,11 @@ sealed class VaultUnlockedNavBarTab : Parcelable { */ abstract val testTag: String + /** + * The amount of notifications for items that fall under this tab. + */ + abstract val notificationCount: Int + /** * Show the Generator screen. */ @@ -60,6 +65,7 @@ sealed class VaultUnlockedNavBarTab : Parcelable { override val contentDescriptionRes get() = R.string.generator override val route get() = GENERATOR_GRAPH_ROUTE override val testTag get() = "GeneratorTab" + override val notificationCount get() = 0 } /** @@ -73,6 +79,7 @@ sealed class VaultUnlockedNavBarTab : Parcelable { override val contentDescriptionRes get() = R.string.send override val route get() = SEND_GRAPH_ROUTE override val testTag get() = "SendTab" + override val notificationCount get() = 0 } /** @@ -87,13 +94,16 @@ sealed class VaultUnlockedNavBarTab : Parcelable { override val iconRes get() = R.drawable.ic_vault override val route get() = VAULT_GRAPH_ROUTE override val testTag get() = "VaultTab" + override val notificationCount get() = 0 } /** * Show the Settings screen. */ @Parcelize - data object Settings : VaultUnlockedNavBarTab() { + data class Settings( + override val notificationCount: Int = 0, + ) : VaultUnlockedNavBarTab() { override val iconResSelected get() = R.drawable.ic_settings_filled override val iconRes get() = R.drawable.ic_settings override val labelRes get() = R.string.settings diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index a840a54326..43fd5d400a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -11,6 +11,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test @@ -182,15 +183,47 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarState( vaultNavBarLabelRes = R.string.vaults, vaultNavBarContentDescriptionRes = R.string.vaults, + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), ), ) composeTestRule.onNodeWithText("My vault").assertDoesNotExist() composeTestRule.onNodeWithText("Vaults").assertExists() } + + @Suppress("MaxLineLength") + @Test + fun `settings tab notification count should update according to state and show correct count`() { + mutableStateFlow.update { + it.copy( + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 1, + ), + ) + } + composeTestRule + .onNodeWithText("1", useUnmergedTree = true) + .assertExists() + + mutableStateFlow.update { + it.copy( + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), + ) + } + composeTestRule + .onNodeWithText("1", useUnmergedTree = true) + .assertDoesNotExist() + } } private val DEFAULT_STATE = VaultUnlockedNavBarState( vaultNavBarLabelRes = R.string.my_vault, vaultNavBarContentDescriptionRes = R.string.my_vault, + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt index 70958886af..7f6be985cd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt @@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.just @@ -27,6 +28,11 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() { every { specialCircumstance } returns null } + private val mutableSettingsBadgeCountFlow = MutableStateFlow(0) + private val settingsRepository: SettingsRepository = mockk { + every { allSettingsBadgeCountFlow } returns mutableSettingsBadgeCountFlow + } + @Suppress("MaxLineLength") @Test fun `on init with GeneratorShortcut special circumstance should navigate to the generator screen with shortcut event`() = @@ -98,6 +104,7 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() { val expectedWithOrganizations = VaultUnlockedNavBarState( vaultNavBarLabelRes = R.string.vaults, vaultNavBarContentDescriptionRes = R.string.vaults, + notificationState = DEFAULT_NOTIFICATION_STATE, ) val accountWithoutOrganizations: UserState.Account = mockk { every { userId } returns activeUserId @@ -106,6 +113,7 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() { val expectedWithoutOrganizations = VaultUnlockedNavBarState( vaultNavBarLabelRes = R.string.my_vault, vaultNavBarContentDescriptionRes = R.string.my_vault, + notificationState = DEFAULT_NOTIFICATION_STATE, ) val viewModel = createViewModel() @@ -182,9 +190,55 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() { } } + @Test + fun `Internal action for SettingsNotificationCountUpdate should update notification state`() { + val expectedCount = 3 + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate( + expectedCount, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + notificationState = DEFAULT_NOTIFICATION_STATE.copy( + settingsTabNotificationCount = expectedCount, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `On initialization settings count should be updated from settings repository`() { + val expectedCount = 5 + mutableSettingsBadgeCountFlow.value = expectedCount + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy( + notificationState = DEFAULT_NOTIFICATION_STATE.copy( + settingsTabNotificationCount = expectedCount, + ), + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel() = VaultUnlockedNavBarViewModel( authRepository = authRepository, specialCircumstancesManager = specialCircumstancesManager, + settingsRepository = settingsRepository, ) } + +private val DEFAULT_NOTIFICATION_STATE = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, +) + +private val DEFAULT_STATE = VaultUnlockedNavBarState( + vaultNavBarLabelRes = R.string.my_vault, + vaultNavBarContentDescriptionRes = R.string.my_vault, + notificationState = DEFAULT_NOTIFICATION_STATE, +)