mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
[PM-12592] & [PM-12593] bottom nav notification dot (#3968)
This commit is contained in:
parent
21e1d8b5bc
commit
f349a72f72
6 changed files with 241 additions and 19 deletions
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue