mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 19:28:44 +03:00
[PM-10618] MP guidance screen with info and clickable card to navigate … (#3697)
This commit is contained in:
parent
722726882b
commit
6bb5ef7417
7 changed files with 457 additions and 0 deletions
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue