BIT-72: Adding UI and navigation for the master password hint screen (#720)

This commit is contained in:
Joshua Queen 2024-01-22 19:10:41 -05:00 committed by Álison Fernandes
parent fa0b71df75
commit 427299eddf
11 changed files with 444 additions and 4 deletions

View file

@ -18,6 +18,8 @@ import com.x8bit.bitwarden.ui.auth.feature.login.loginDestination
import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin 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.loginWithDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
const val AUTH_GRAPH_ROUTE: String = "auth_graph" const val AUTH_GRAPH_ROUTE: String = "auth_graph"
@ -58,6 +60,11 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
) )
loginDestination( loginDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToMasterPasswordHint = { emailAddress ->
navController.navigateToMasterPasswordHint(
emailAddress = emailAddress,
)
},
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() }, onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() }, onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() },
) )
@ -67,6 +74,9 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
environmentDestination( environmentDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
) )
masterPasswordHintDestination(
onNavigateBack = { navController.popBackStack() },
)
} }
} }

View file

@ -43,6 +43,7 @@ fun NavController.navigateToLogin(
*/ */
fun NavGraphBuilder.loginDestination( fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit, onNavigateToLoginWithDevice: () -> Unit,
) { ) {
@ -58,6 +59,7 @@ fun NavGraphBuilder.loginDestination(
) { ) {
LoginScreen( LoginScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToMasterPasswordHint = onNavigateToMasterPasswordHint,
onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn, onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn,
onNavigateToLoginWithDevice = onNavigateToLoginWithDevice, onNavigateToLoginWithDevice = onNavigateToLoginWithDevice,
) )

View file

@ -64,6 +64,7 @@ import kotlinx.collections.immutable.toImmutableList
@Suppress("LongMethod") @Suppress("LongMethod")
fun LoginScreen( fun LoginScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit, onNavigateToLoginWithDevice: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
@ -74,6 +75,9 @@ fun LoginScreen(
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
LoginEvent.NavigateBack -> onNavigateBack() LoginEvent.NavigateBack -> onNavigateBack()
is LoginEvent.NavigateToMasterPasswordHint -> {
onNavigateToMasterPasswordHint(event.emailAddress)
}
is LoginEvent.NavigateToCaptcha -> { is LoginEvent.NavigateToCaptcha -> {
intentManager.startCustomTabsActivity(uri = event.uri) intentManager.startCustomTabsActivity(uri = event.uri)
} }

View file

@ -238,8 +238,8 @@ class LoginViewModel @Inject constructor(
} }
private fun handleMasterPasswordHintClicked() { private fun handleMasterPasswordHintClicked() {
// TODO: Navigate to master password hint screen (BIT-72) val email = mutableStateFlow.value.emailAddress
sendEvent(LoginEvent.ShowToast("Not yet implemented.")) sendEvent(LoginEvent.NavigateToMasterPasswordHint(email))
} }
private fun handleNotYouButtonClicked() { private fun handleNotYouButtonClicked() {
@ -285,6 +285,13 @@ sealed class LoginEvent {
*/ */
data object NavigateBack : LoginEvent() data object NavigateBack : LoginEvent()
/**
* Navigate to the master password hit screen.
*/
data class NavigateToMasterPasswordHint(
val emailAddress: String,
) : LoginEvent()
/** /**
* Navigates to the captcha verification screen. * Navigates to the captcha verification screen.
*/ */

View file

@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL_ADDRESS: String = "email_address"
private const val MASTER_PASSWORD_HINT_ROUTE: String = "master_password_hint/{$EMAIL_ADDRESS}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class MasterPasswordHintArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
)
}
/**
* Navigate to the master password hint screen.
*/
fun NavController.navigateToMasterPasswordHint(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("master_password_hint/$emailAddress", navOptions)
}
/**
* Add the master password hint screen to the nav graph.
*/
fun NavGraphBuilder.masterPasswordHintDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = MASTER_PASSWORD_HINT_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
),
) {
MasterPasswordHintScreen(onNavigateBack = onNavigateBack)
}
}

View file

@ -0,0 +1,132 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
/**
* The top level composable for the Login screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MasterPasswordHintScreen(
onNavigateBack: () -> Unit,
viewModel: MasterPasswordHintViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
MasterPasswordHintEvent.NavigateBack -> onNavigateBack()
}
}
when (val dialogState = state.dialog) {
is MasterPasswordHintState.DialogState.PasswordHintSent -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.password_hint.asText(),
message = R.string.password_hint_alert.asText(),
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(MasterPasswordHintAction.DismissDialog) }
},
)
}
is MasterPasswordHintState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.password_hint.asText(),
message = dialogState.message,
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(MasterPasswordHintAction.DismissDialog) }
},
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.password_hint),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(MasterPasswordHintAction.CloseClick) }
},
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.submit),
onClick = remember(viewModel) {
{ viewModel.trySendAction(MasterPasswordHintAction.SubmitClick) }
},
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
BitwardenTextField(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
value = state.emailInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(it)) }
},
label = stringResource(id = R.string.email_address),
keyboardType = KeyboardType.Email,
)
Text(
text = stringResource(id = R.string.enter_email_for_hint),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(
vertical = 4.dp,
horizontal = 16.dp,
),
)
}
}
}

View file

@ -0,0 +1,135 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the master password hint screen.
*/
@HiltViewModel
class MasterPasswordHintViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<MasterPasswordHintState, MasterPasswordHintEvent, MasterPasswordHintAction>(
initialState = savedStateHandle[KEY_STATE]
?: MasterPasswordHintState(
emailInput = MasterPasswordHintArgs(savedStateHandle).emailAddress,
),
) {
init {
stateFlow
.onEach {
savedStateHandle[KEY_STATE] = it
}
.launchIn(viewModelScope)
}
override fun handleAction(action: MasterPasswordHintAction) {
when (action) {
MasterPasswordHintAction.CloseClick -> handleCloseClick()
MasterPasswordHintAction.SubmitClick -> handleSubmitClick()
is MasterPasswordHintAction.EmailInputChange -> handleEmailInputUpdated(action)
MasterPasswordHintAction.DismissDialog -> handleDismissDialog()
}
}
private fun handleCloseClick() {
sendEvent(
event = MasterPasswordHintEvent.NavigateBack,
)
}
private fun handleSubmitClick() {
// TODO (BIT-71): Implement master password hint
}
private fun handleEmailInputUpdated(action: MasterPasswordHintAction.EmailInputChange) {
val email = action.input
mutableStateFlow.update {
it.copy(
emailInput = email,
)
}
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
}
/**
* Models state of the landing screen.
*/
@Parcelize
data class MasterPasswordHintState(
val dialog: DialogState? = null,
val emailInput: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents a dialog indicating that the password hint was sent.
*/
@Parcelize
data object PasswordHintSent : DialogState()
/**
* Represents an error dialog with the given [message].
*/
@Parcelize
data class Error(
val message: Text,
) : DialogState()
}
}
/**
* Models events for the master password hint screen.
*/
sealed class MasterPasswordHintEvent {
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : MasterPasswordHintEvent()
}
/**
* Models actions for the login screen.
*/
sealed class MasterPasswordHintAction {
/**
* Indicates that the top-bar close button was clicked.
*/
data object CloseClick : MasterPasswordHintAction()
/**
* Indicates that the top-bar submit button was clicked.
*/
data object SubmitClick : MasterPasswordHintAction()
/**
* Indicates that the input on the email field has changed.
*/
data class EmailInputChange(val input: String) : MasterPasswordHintAction()
/**
* User dismissed the currently displayed dialog.
*/
data object DismissDialog : MasterPasswordHintAction()
}

View file

@ -45,6 +45,7 @@ class LoginScreenTest : BaseComposeTest() {
every { startCustomTabsActivity(any()) } returns Unit every { startCustomTabsActivity(any()) } returns Unit
} }
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onNavigateToMasterPasswordHintCalled = false
private var onNavigateToEnterpriseSignOnCalled = false private var onNavigateToEnterpriseSignOnCalled = false
private var onNavigateToLoginWithDeviceCalled = false private var onNavigateToLoginWithDeviceCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<LoginEvent>() private val mutableEventFlow = bufferedMutableSharedFlow<LoginEvent>()
@ -59,6 +60,7 @@ class LoginScreenTest : BaseComposeTest() {
composeTestRule.setContent { composeTestRule.setContent {
LoginScreen( LoginScreen(
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true },
onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true }, onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true },
onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true }, onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true },
viewModel = viewModel, viewModel = viewModel,
@ -279,6 +281,12 @@ class LoginScreenTest : BaseComposeTest() {
verify { intentManager.startCustomTabsActivity(mockUri) } verify { intentManager.startCustomTabsActivity(mockUri) }
} }
@Test
fun `NavigateToMasterPasswordHint should call onNavigateToMasterPasswordHint`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToMasterPasswordHint("email"))
assertTrue(onNavigateToMasterPasswordHintCalled)
}
@Test @Test
fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() { fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn) mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn)

View file

@ -336,13 +336,13 @@ class LoginViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `MasterPasswordHintClick should emit ShowToast`() = runTest { fun `MasterPasswordHintClick should emit NavigateToMasterPasswordHint`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.MasterPasswordHintClick) viewModel.actionChannel.trySend(LoginAction.MasterPasswordHintClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals( assertEquals(
LoginEvent.ShowToast("Not yet implemented."), LoginEvent.NavigateToMasterPasswordHint("test@gmail.com"),
awaitItem(), awaitItem(),
) )
} }

View file

@ -0,0 +1,54 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextReplacement
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 kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class MasterPasswordHintScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<MasterPasswordHintEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<MasterPasswordHintViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
MasterPasswordHintScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `email input change should send EmailInputChange action`() {
val emailInput = "newEmail"
composeTestRule.onNodeWithText("currentEmail").performTextReplacement(emailInput)
verify {
viewModel.trySendAction(MasterPasswordHintAction.EmailInputChange(emailInput))
}
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(MasterPasswordHintEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
}
private val DEFAULT_STATE =
MasterPasswordHintState(
emailInput = "currentEmail",
)

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class MasterPasswordHintViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `on CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordHintAction.CloseClick)
assertEquals(MasterPasswordHintEvent.NavigateBack, awaitItem())
}
}
private fun createViewModel(
state: MasterPasswordHintState? = DEFAULT_STATE,
): MasterPasswordHintViewModel = MasterPasswordHintViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
}
private val DEFAULT_STATE: MasterPasswordHintState = MasterPasswordHintState(
emailInput = "email",
)