BIT-556 BIT-190 Update current CreateAccountScreen to match designs (#92)

This commit is contained in:
Andrew Haisting 2023-10-03 16:39:59 -05:00 committed by Álison Fernandes
parent eedf0b6f91
commit 38155dbefd
9 changed files with 237 additions and 65 deletions

View file

@ -22,7 +22,7 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
startDestination = LANDING_ROUTE,
route = AUTH_ROUTE,
) {
createAccountDestinations()
createAccountDestinations(onNavigateBack = { navController.popBackStack() })
landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress, regionLabel ->

View file

@ -17,8 +17,10 @@ fun NavController.navigateToCreateAccount(navOptions: NavOptions? = null) {
/**
* Add the create account screen to the nav graph.
*/
fun NavGraphBuilder.createAccountDestinations() {
fun NavGraphBuilder.createAccountDestinations(
onNavigateBack: () -> Unit,
) {
composable(route = CREATE_ACCOUNT_ROUTE) {
CreateAccountScreen()
CreateAccountScreen(onNavigateBack)
}
}

View file

@ -2,21 +2,19 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -25,8 +23,10 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Con
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
/**
@ -35,12 +35,14 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod")
@Composable
fun CreateAccountScreen(
onNavigateBack: () -> Unit,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
EventsEffect(viewModel) { event ->
when (event) {
is CreateAccountEvent.NavigateBack -> onNavigateBack.invoke()
is CreateAccountEvent.ShowToast -> {
Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
}
@ -51,56 +53,60 @@ fun CreateAccountScreen(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
verticalArrangement = spacedBy(8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primary),
verticalAlignment = Alignment.CenterVertically,
BitwardenTextButtonTopAppBar(
title = stringResource(id = R.string.create_account),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CreateAccountAction.CloseClick) }
},
buttonText = stringResource(id = R.string.submit),
onButtonClick = remember(viewModel) {
{ viewModel.trySendAction(CreateAccountAction.SubmitClick) }
},
isButtonEnabled = state.isSubmitEnabled,
)
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = spacedBy(16.dp),
) {
Text(
modifier = Modifier
.weight(1f)
.padding(16.dp),
text = stringResource(id = R.string.create_account),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.titleLarge,
BitwardenTextField(
label = stringResource(id = R.string.email_address),
value = state.emailInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(EmailInputChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
)
Text(
modifier = Modifier
.clickable {
viewModel.trySendAction(SubmitClick)
}
.padding(16.dp),
text = stringResource(id = R.string.submit),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.bodyMedium,
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
onValueChange = remember {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = remember { { viewModel.trySendAction(PasswordHintChange(it)) } },
modifier = Modifier.fillMaxWidth(),
)
BitwardenFilledButton(
label = stringResource(id = R.string.submit),
onClick = remember { { viewModel.trySendAction(CreateAccountAction.SubmitClick) } },
isEnabled = state.isSubmitEnabled,
modifier = Modifier.fillMaxWidth(),
)
}
BitwardenTextField(
label = stringResource(id = R.string.email_address),
value = state.emailInput,
onValueChange = { viewModel.trySendAction(EmailInputChange(it)) },
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = { viewModel.trySendAction(PasswordInputChange(it)) },
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
onValueChange = { viewModel.trySendAction(ConfirmPasswordInputChange(it)) },
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = { viewModel.trySendAction(PasswordHintChange(it)) },
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -31,6 +31,7 @@ class CreateAccountViewModel @Inject constructor(
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isSubmitEnabled = false,
),
) {
@ -48,9 +49,14 @@ class CreateAccountViewModel @Inject constructor(
is EmailInputChange -> handleEmailInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action)
is CreateAccountAction.CloseClick -> handleCloseClick()
}
}
private fun handleCloseClick() {
sendEvent(CreateAccountEvent.NavigateBack)
}
private fun handleEmailInputChanged(action: EmailInputChange) {
mutableStateFlow.update { it.copy(emailInput = action.input) }
}
@ -81,6 +87,7 @@ data class CreateAccountState(
val passwordInput: String,
val confirmPasswordInput: String,
val passwordHintInput: String,
val isSubmitEnabled: Boolean,
) : Parcelable
/**
@ -88,6 +95,11 @@ data class CreateAccountState(
*/
sealed class CreateAccountEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CreateAccountEvent()
/**
* Placeholder event for showing a toast. Can be removed once there are real events.
*/
@ -103,6 +115,11 @@ sealed class CreateAccountAction {
*/
data object SubmitClick : CreateAccountAction()
/**
* User clicked close.
*/
data object CloseClick : CreateAccountAction()
/**
* Email input changed.
*/

View file

@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -26,7 +27,7 @@ fun BitwardenFilledButton(
) {
Button(
onClick = onClick,
modifier = modifier,
modifier = modifier.semantics(mergeDescendants = true) {},
enabled = isEnabled,
contentPadding = PaddingValues(
vertical = 10.dp,
@ -35,7 +36,11 @@ fun BitwardenFilledButton(
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onPrimary,
color = if (isEnabled) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
},
style = MaterialTheme.typography.labelLarge,
)
}

View file

@ -21,14 +21,20 @@ fun BitwardenTextButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
TextButton(
onClick = onClick,
modifier = modifier,
enabled = isEnabled,
) {
Text(
text = label,
color = MaterialTheme.colorScheme.primary,
color = if (isEnabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
},
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.padding(

View file

@ -0,0 +1,77 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
/**
* Represents a Bitwarden styled [TopAppBar] that assumes the following components:
*
* - a single navigation control in the upper-left defined by [navigationIcon],
* [navigationIconContentDescription], and [onNavigationIconClick].
* - a [title] in the middle.
* - a [BitwardenTextButton] on the right that will display [buttonText] and call [onButtonClick]
* when clicked and [isButtonEnabled] is true.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwardenTextButtonTopAppBar(
title: String,
navigationIcon: Painter,
navigationIconContentDescription: String,
onNavigationIconClick: () -> Unit,
buttonText: String,
onButtonClick: () -> Unit,
isButtonEnabled: Boolean,
) {
TopAppBar(
navigationIcon = {
IconButton(
onClick = { onNavigationIconClick() },
) {
Icon(
painter = navigationIcon,
contentDescription = navigationIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
},
actions = {
BitwardenTextButton(
label = buttonText,
onClick = onButtonClick,
isEnabled = isButtonEnabled,
)
},
)
}
@Preview
@Composable
private fun BitwardenTextButtonTopAppBar_preview() {
BitwardenTextButtonTopAppBar(
title = "Title",
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = {},
buttonText = "Button",
onButtonClick = {},
isButtonEnabled = true,
)
}

View file

@ -1,8 +1,11 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
@ -14,24 +17,67 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
class CreateAccountScreenTest : BaseComposeTest() {
@Test
fun `submit click should send SubmitClick action`() {
fun `app bar submit click should send SubmitClick action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE.copy(isSubmitEnabled = true))
every { eventFlow } returns emptyFlow()
every { trySendAction(SubmitClick) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(viewModel)
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Submit").performClick()
composeTestRule.onAllNodesWithText("Submit")[0].performClick()
verify { viewModel.trySendAction(SubmitClick) }
}
@Test
fun `bottom button submit click should send SubmitClick action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE.copy(isSubmitEnabled = true))
every { eventFlow } returns emptyFlow()
every { trySendAction(SubmitClick) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onAllNodesWithText("Submit")[1].performClick()
verify { viewModel.trySendAction(SubmitClick) }
}
@Test
fun `close click should send CloseClick action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns emptyFlow()
every { trySendAction(CloseClick) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
}
@Test
fun `NavigateBack event should invoke navigate back lambda`() {
var onNavigateBackCalled = false
val onNavigateBack = { onNavigateBackCalled = true }
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns flowOf(CreateAccountEvent.NavigateBack)
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = onNavigateBack, viewModel = viewModel)
}
assert(onNavigateBackCalled)
}
@Test
fun `email input change should send EmailInputChange action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
@ -40,7 +86,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(EmailInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(viewModel)
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) }
@ -54,7 +100,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(PasswordInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(viewModel)
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) }
@ -68,7 +114,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(ConfirmPasswordInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(viewModel)
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) }
@ -82,7 +128,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(PasswordHintChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(viewModel)
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
}
composeTestRule
.onNodeWithText("Master password hint (optional)")
@ -97,6 +143,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isSubmitEnabled = false,
)
}
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
@ -27,6 +28,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordInput = "password",
confirmPasswordInput = "confirmPassword",
passwordHintInput = "hint",
isSubmitEnabled = false,
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel(handle)
@ -42,6 +44,15 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CloseClick)
assert(awaitItem() is CreateAccountEvent.NavigateBack)
}
}
@Test
fun `ConfirmPasswordInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
@ -84,6 +95,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
emailInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isSubmitEnabled = false,
)
}
}