1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-03-08 15:36:31 +03:00

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

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
app/src
main/java/com/x8bit/bitwarden/ui/platform
test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar

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 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.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets 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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.max import com.x8bit.bitwarden.ui.platform.base.util.max
import com.x8bit.bitwarden.ui.platform.base.util.toDp 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.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.scrim.BitwardenAnimatedScrim import com.x8bit.bitwarden.ui.platform.components.scrim.BitwardenAnimatedScrim
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
@ -267,7 +271,7 @@ private fun VaultBottomAppBar(
), ),
VaultUnlockedNavBarTab.Send, VaultUnlockedNavBarTab.Send,
VaultUnlockedNavBarTab.Generator, VaultUnlockedNavBarTab.Generator,
VaultUnlockedNavBarTab.Settings, VaultUnlockedNavBarTab.Settings(state.notificationState.settingsTabNotificationCount),
) )
// Collecting the back stack entry here as state is crucial to ensuring that the items // 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. // below recompose when the navigation state changes to update the selected tab.
@ -277,18 +281,27 @@ private fun VaultBottomAppBar(
NavigationBarItem( NavigationBarItem(
icon = { icon = {
Icon( BadgedBox(
painter = rememberVectorPainter( badge = {
id = if (isSelected) { NotificationBadge(
destination.iconResSelected notificationCount = destination.notificationCount,
} else { isVisible = destination.notificationCount > 0,
destination.iconRes )
}, },
), ) {
contentDescription = stringResource( Icon(
id = destination.contentDescriptionRes, painter = rememberVectorPainter(
), id = if (isSelected) {
) destination.iconResSelected
} else {
destination.iconRes
},
),
contentDescription = stringResource(
id = destination.contentDescriptionRes,
),
)
}
}, },
label = { label = {
Text( Text(
@ -303,7 +316,7 @@ private fun VaultBottomAppBar(
is VaultUnlockedNavBarTab.Vault -> vaultTabClickedAction() is VaultUnlockedNavBarTab.Vault -> vaultTabClickedAction()
VaultUnlockedNavBarTab.Send -> sendTabClickedAction() VaultUnlockedNavBarTab.Send -> sendTabClickedAction()
VaultUnlockedNavBarTab.Generator -> generatorTabClickedAction() VaultUnlockedNavBarTab.Generator -> generatorTabClickedAction()
VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction() is VaultUnlockedNavBarTab.Settings -> settingsTabClickedAction()
} }
}, },
colors = NavigationBarItemDefaults.colors( 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.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance 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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab
@ -23,10 +24,14 @@ import javax.inject.Inject
class VaultUnlockedNavBarViewModel @Inject constructor( class VaultUnlockedNavBarViewModel @Inject constructor(
authRepository: AuthRepository, authRepository: AuthRepository,
specialCircumstancesManager: SpecialCircumstanceManager, specialCircumstancesManager: SpecialCircumstanceManager,
settingsRepository: SettingsRepository,
) : BaseViewModel<VaultUnlockedNavBarState, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>( ) : BaseViewModel<VaultUnlockedNavBarState, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>(
initialState = VaultUnlockedNavBarState( initialState = VaultUnlockedNavBarState(
vaultNavBarLabelRes = R.string.my_vault, vaultNavBarLabelRes = R.string.my_vault,
vaultNavBarContentDescriptionRes = R.string.my_vault, vaultNavBarContentDescriptionRes = R.string.my_vault,
notificationState = VaultUnlockedNavBarNotificationState(
settingsTabNotificationCount = settingsRepository.allSettingsBadgeCountFlow.value,
),
), ),
) { ) {
init { init {
@ -37,6 +42,13 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
settingsRepository
.allSettingsBadgeCountFlow
.onEach {
sendAction(VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate(it))
}
.launchIn(viewModelScope)
when (specialCircumstancesManager.specialCircumstance) { when (specialCircumstancesManager.specialCircumstance) {
SpecialCircumstance.GeneratorShortcut -> { SpecialCircumstance.GeneratorShortcut -> {
sendEvent(VaultUnlockedNavBarEvent.Shortcut.NavigateToGeneratorScreen) sendEvent(VaultUnlockedNavBarEvent.Shortcut.NavigateToGeneratorScreen)
@ -72,6 +84,10 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
is VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive -> { is VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive -> {
handleUserStateUpdateReceive(action) handleUserStateUpdateReceive(action)
} }
is VaultUnlockedNavBarAction.Internal.SettingsNotificationCountUpdate -> {
handleSettingsNotificationCountUpdate(action)
}
} }
} }
// #region BottomTabViewModel Action Handlers // #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 // #endregion BottomTabViewModel Action Handlers
} }
@ -137,6 +165,14 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
data class VaultUnlockedNavBarState( data class VaultUnlockedNavBarState(
@StringRes val vaultNavBarLabelRes: Int, @StringRes val vaultNavBarLabelRes: Int,
@StringRes val vaultNavBarContentDescriptionRes: 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. * Indicates a change in user state has been received.
*/ */
data class UserStateUpdateReceive( data class UserStateUpdateReceive(val userState: UserState?) : Internal()
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. * Navigate to the Settings screen.
*/ */
data object NavigateToSettingsScreen : VaultUnlockedNavBarEvent() { 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 abstract val testTag: String
/**
* The amount of notifications for items that fall under this tab.
*/
abstract val notificationCount: Int
/** /**
* Show the Generator screen. * Show the Generator screen.
*/ */
@ -60,6 +65,7 @@ sealed class VaultUnlockedNavBarTab : Parcelable {
override val contentDescriptionRes get() = R.string.generator override val contentDescriptionRes get() = R.string.generator
override val route get() = GENERATOR_GRAPH_ROUTE override val route get() = GENERATOR_GRAPH_ROUTE
override val testTag get() = "GeneratorTab" 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 contentDescriptionRes get() = R.string.send
override val route get() = SEND_GRAPH_ROUTE override val route get() = SEND_GRAPH_ROUTE
override val testTag get() = "SendTab" 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 iconRes get() = R.drawable.ic_vault
override val route get() = VAULT_GRAPH_ROUTE override val route get() = VAULT_GRAPH_ROUTE
override val testTag get() = "VaultTab" override val testTag get() = "VaultTab"
override val notificationCount get() = 0
} }
/** /**
* Show the Settings screen. * Show the Settings screen.
*/ */
@Parcelize @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 iconResSelected get() = R.drawable.ic_settings_filled
override val iconRes get() = R.drawable.ic_settings override val iconRes get() = R.drawable.ic_settings
override val labelRes get() = R.string.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.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -182,15 +183,47 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarState( VaultUnlockedNavBarState(
vaultNavBarLabelRes = R.string.vaults, vaultNavBarLabelRes = R.string.vaults,
vaultNavBarContentDescriptionRes = R.string.vaults, vaultNavBarContentDescriptionRes = R.string.vaults,
notificationState = VaultUnlockedNavBarNotificationState(
settingsTabNotificationCount = 0,
),
), ),
) )
composeTestRule.onNodeWithText("My vault").assertDoesNotExist() composeTestRule.onNodeWithText("My vault").assertDoesNotExist()
composeTestRule.onNodeWithText("Vaults").assertExists() 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( private val DEFAULT_STATE = VaultUnlockedNavBarState(
vaultNavBarLabelRes = R.string.my_vault, vaultNavBarLabelRes = R.string.my_vault,
vaultNavBarContentDescriptionRes = 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.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance 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 com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
@ -27,6 +28,11 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
every { specialCircumstance } returns null every { specialCircumstance } returns null
} }
private val mutableSettingsBadgeCountFlow = MutableStateFlow(0)
private val settingsRepository: SettingsRepository = mockk {
every { allSettingsBadgeCountFlow } returns mutableSettingsBadgeCountFlow
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `on init with GeneratorShortcut special circumstance should navigate to the generator screen with shortcut event`() = 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( val expectedWithOrganizations = VaultUnlockedNavBarState(
vaultNavBarLabelRes = R.string.vaults, vaultNavBarLabelRes = R.string.vaults,
vaultNavBarContentDescriptionRes = R.string.vaults, vaultNavBarContentDescriptionRes = R.string.vaults,
notificationState = DEFAULT_NOTIFICATION_STATE,
) )
val accountWithoutOrganizations: UserState.Account = mockk { val accountWithoutOrganizations: UserState.Account = mockk {
every { userId } returns activeUserId every { userId } returns activeUserId
@ -106,6 +113,7 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
val expectedWithoutOrganizations = VaultUnlockedNavBarState( val expectedWithoutOrganizations = VaultUnlockedNavBarState(
vaultNavBarLabelRes = R.string.my_vault, vaultNavBarLabelRes = R.string.my_vault,
vaultNavBarContentDescriptionRes = R.string.my_vault, vaultNavBarContentDescriptionRes = R.string.my_vault,
notificationState = DEFAULT_NOTIFICATION_STATE,
) )
val viewModel = createViewModel() 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() = private fun createViewModel() =
VaultUnlockedNavBarViewModel( VaultUnlockedNavBarViewModel(
authRepository = authRepository, authRepository = authRepository,
specialCircumstancesManager = specialCircumstancesManager, 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,
)