[PM-10618] MP guidance screen with info and clickable card to navigate … (#3697)

This commit is contained in:
Dave Severns 2024-08-08 16:53:56 -04:00 committed by GitHub
parent 722726882b
commit 6bb5ef7417
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 457 additions and 0 deletions

View file

@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.masterPasswordGuidanceDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
@ -123,6 +124,12 @@ fun NavGraphBuilder.authGraph(
twoFactorLoginDestination(
onNavigateBack = { navController.popBackStack() },
)
masterPasswordGuidanceDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToGeneratePassword = {
// TODO [PM-10619](https://bitwarden.atlassian.net/browse/PM-10619)
},
)
}
}

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val MASTER_PASSWORD_GUIDANCE = "master_password_guidance"
/**
* Navigate to the master password guidance screen.
*/
fun NavController.navigateToMasterPasswordGuidance(navOptions: NavOptions? = null) {
this.navigate(MASTER_PASSWORD_GUIDANCE, navOptions)
}
/**
* Add the master password guidance screen to the nav graph.
*/
fun NavGraphBuilder.masterPasswordGuidanceDestination(
onNavigateBack: () -> Unit,
onNavigateToGeneratePassword: () -> Unit,
) {
composableWithSlideTransitions(
route = MASTER_PASSWORD_GUIDANCE,
) {
MasterPasswordGuidanceScreen(
onNavigateBack = onNavigateBack,
onNavigateToGeneratePassword = onNavigateToGeneratePassword,
)
}
}

View file

@ -0,0 +1,251 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
private const val BULLET_TWO_TAB = "\u2022\t\t"
/**
* The top level composable for the Master Password Guidance screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun MasterPasswordGuidanceScreen(
onNavigateBack: () -> Unit,
onNavigateToGeneratePassword: () -> Unit,
viewModel: MasterPasswordGuidanceViewModel = hiltViewModel(),
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
MasterPasswordGuidanceEvent.NavigateBack -> onNavigateBack()
MasterPasswordGuidanceEvent.NavigateToPasswordGenerator -> {
onNavigateToGeneratePassword()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.master_password),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{
viewModel.trySendAction(MasterPasswordGuidanceAction.CloseAction)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(innerPadding)
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(size = 4.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 24.dp),
) {
Text(
text = stringResource(R.string.what_makes_a_password_strong),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = stringResource(
R.string.the_longer_your_password_the_more_difficult_to_hack,
),
)
}
HorizontalDivider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Text(
text = stringResource(R.string.the_strongest_passwords_are_usually),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
BulletTextRow(text = stringResource(R.string.twelve_or_more_characters))
BulletTextRow(
text = stringResource(
R.string.random_and_complex_using_numbers_and_special_characters,
),
)
BulletTextRow(
text = stringResource(R.string.totally_different_from_your_other_passwords),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
TryGeneratorCard(
onCardClicked = remember(viewModel) {
{
viewModel.trySendAction(
MasterPasswordGuidanceAction.TryPasswordGeneratorAction,
)
}
},
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun TryGeneratorCard(
onCardClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
onClick = onCardClicked,
shape = RoundedCornerShape(size = 16.dp),
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
),
elevation = CardDefaults.elevatedCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_generator),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(weight = 1f),
) {
Text(
text = stringResource(
R.string.use_the_generator_to_create_a_strong_unique_password,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.try_it_out),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(16.dp))
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_navigate_next),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.CenterVertically)
.size(16.dp),
)
}
}
}
@Composable
private fun BulletTextRow(
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = BULLET_TWO_TAB,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clearAndSetSemantics { },
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview
@Composable
private fun MasterPasswordGuidanceScreenPreview() {
BitwardenTheme {
MasterPasswordGuidanceScreen(
onNavigateBack = {},
onNavigateToGeneratePassword = {},
)
}
}

View file

@ -0,0 +1,64 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* ViewModel for the [MasterPasswordGuidanceScreen]
*/
@HiltViewModel
class MasterPasswordGuidanceViewModel @Inject constructor() :
BaseViewModel<Unit, MasterPasswordGuidanceEvent, MasterPasswordGuidanceAction>(
initialState = Unit,
) {
override fun handleAction(action: MasterPasswordGuidanceAction) {
when (action) {
MasterPasswordGuidanceAction.CloseAction -> handleCloseAction()
MasterPasswordGuidanceAction.TryPasswordGeneratorAction -> {
handleTryPasswordGeneratorAction()
}
}
}
private fun handleTryPasswordGeneratorAction() {
sendEvent(MasterPasswordGuidanceEvent.NavigateToPasswordGenerator)
}
private fun handleCloseAction() {
sendEvent(MasterPasswordGuidanceEvent.NavigateBack)
}
}
/**
* Models events for the [MasterPasswordGuidanceScreen]
*/
sealed class MasterPasswordGuidanceEvent {
/**
* Navigates back to the previous screen
*/
data object NavigateBack : MasterPasswordGuidanceEvent()
/**
* Navigates to the MasterPasswordGenerationScreen
*/
data object NavigateToPasswordGenerator : MasterPasswordGuidanceEvent()
}
/**
* Models user actions on the [MasterPasswordGuidanceScreen]
*/
sealed class MasterPasswordGuidanceAction {
/**
* User has clicked the close button
*/
data object CloseAction : MasterPasswordGuidanceAction()
/**
* User has clicked the try generator card
*/
data object TryPasswordGeneratorAction : MasterPasswordGuidanceAction()
}

View file

@ -937,4 +937,12 @@ Do you want to switch to this account?</string>
<string name="welcome_message_4">Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps.</string>
<string name="remove_passkey">Remove passkey</string>
<string name="passkey_removed">Passkey removed</string>
<string name="what_makes_a_password_strong">What makes a password strong?</string>
<string name="the_longer_your_password_the_more_difficult_to_hack">The longer your password, the more difficult it is to hack. The minimum for account creation is 12 characters but if you do 14 characters, the time to crack your password would be centuries!</string>
<string name="the_strongest_passwords_are_usually">The strongest passwords are usually:</string>
<string name="twelve_or_more_characters">12 or more characters</string>
<string name="random_and_complex_using_numbers_and_special_characters">Random and complex, using numbers and special characters</string>
<string name="totally_different_from_your_other_passwords">Totally different from your other passwords</string>
<string name="use_the_generator_to_create_a_strong_unique_password">Use the generator to create a strong, unique password</string>
<string name="try_it_out">Try it out</string>
</resources>

View file

@ -0,0 +1,67 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class MasterPasswordGuidanceScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToGeneratorCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<MasterPasswordGuidanceEvent>()
private val viewModel = mockk<MasterPasswordGuidanceViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
}
@Before
fun setup() {
composeTestRule.setContent {
MasterPasswordGuidanceScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToGeneratePassword = { onNavigateToGeneratorCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `Close button click should invoke send of CloseAction`() {
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify { viewModel.trySendAction(MasterPasswordGuidanceAction.CloseAction) }
}
@Test
fun `Generator card click should invoke send of TryPasswordGeneratorAction`() {
composeTestRule
.onNodeWithText("Use the generator to create a strong, unique password")
.performClick()
verify { viewModel.trySendAction(MasterPasswordGuidanceAction.TryPasswordGeneratorAction) }
}
@Test
fun `NavigateBack event should invoke onNavigateBack lambda`() {
assertFalse(onNavigateBackCalled)
mutableEventFlow.tryEmit(MasterPasswordGuidanceEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToPasswordGenerator event should invoke onNavigateToGeneratePassword lambda`() {
assertFalse(onNavigateToGeneratorCalled)
mutableEventFlow.tryEmit(MasterPasswordGuidanceEvent.NavigateToPasswordGenerator)
assertTrue(onNavigateToGeneratorCalled)
}
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class MasterPasswordGuidanceViewModelTest : BaseViewModelTest() {
@Test
fun `CloseAction should cause NavigateBack event to emit`() = runTest {
val viewModel = MasterPasswordGuidanceViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGuidanceAction.CloseAction)
assertEquals(MasterPasswordGuidanceEvent.NavigateBack, awaitItem())
}
}
@Test
fun `TryPasswordGeneratorAction should cause NavigateToPasswordGenerator to emit`() = runTest {
val viewModel = MasterPasswordGuidanceViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGuidanceAction.TryPasswordGeneratorAction)
assertEquals(MasterPasswordGuidanceEvent.NavigateToPasswordGenerator, awaitItem())
}
}
}