[PM-12592] & [PM-12593] bottom nav notification dot (#3968)

This commit is contained in:
Dave Severns 2024-09-26 13:04:40 -04:00 committed by GitHub
parent 21e1d8b5bc
commit f349a72f72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 241 additions and 19 deletions

View file

@ -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,
)
}
}

View file

@ -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(

View file

@ -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<VaultUnlockedNavBarState, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>(
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()
}
/**

View file

@ -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

View file

@ -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,
),
)

View file

@ -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,
)