diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index ea620f585..5832e28b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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.loginwithdevice.loginWithDeviceDestination 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" @@ -58,6 +60,11 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { ) loginDestination( onNavigateBack = { navController.popBackStack() }, + onNavigateToMasterPasswordHint = { emailAddress -> + navController.navigateToMasterPasswordHint( + emailAddress = emailAddress, + ) + }, onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() }, onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() }, ) @@ -67,6 +74,9 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { environmentDestination( onNavigateBack = { navController.popBackStack() }, ) + masterPasswordHintDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index fdc3dd993..565530b1f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -43,6 +43,7 @@ fun NavController.navigateToLogin( */ fun NavGraphBuilder.loginDestination( onNavigateBack: () -> Unit, + onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToLoginWithDevice: () -> Unit, ) { @@ -58,6 +59,7 @@ fun NavGraphBuilder.loginDestination( ) { LoginScreen( onNavigateBack = onNavigateBack, + onNavigateToMasterPasswordHint = onNavigateToMasterPasswordHint, onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn, onNavigateToLoginWithDevice = onNavigateToLoginWithDevice, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index a888484cd..7c38c8cdf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -64,6 +64,7 @@ import kotlinx.collections.immutable.toImmutableList @Suppress("LongMethod") fun LoginScreen( onNavigateBack: () -> Unit, + onNavigateToMasterPasswordHint: (String) -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToLoginWithDevice: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), @@ -74,6 +75,9 @@ fun LoginScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { LoginEvent.NavigateBack -> onNavigateBack() + is LoginEvent.NavigateToMasterPasswordHint -> { + onNavigateToMasterPasswordHint(event.emailAddress) + } is LoginEvent.NavigateToCaptcha -> { intentManager.startCustomTabsActivity(uri = event.uri) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 9ee48bae9..87b20bc22 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -238,8 +238,8 @@ class LoginViewModel @Inject constructor( } private fun handleMasterPasswordHintClicked() { - // TODO: Navigate to master password hint screen (BIT-72) - sendEvent(LoginEvent.ShowToast("Not yet implemented.")) + val email = mutableStateFlow.value.emailAddress + sendEvent(LoginEvent.NavigateToMasterPasswordHint(email)) } private fun handleNotYouButtonClicked() { @@ -285,6 +285,13 @@ sealed class 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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintNavigation.kt new file mode 100644 index 000000000..0d83b4262 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintNavigation.kt @@ -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) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt new file mode 100644 index 000000000..da3d46629 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreen.kt @@ -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, + ), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt new file mode 100644 index 000000000..482f36c78 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModel.kt @@ -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( + 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() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 6a797f6b9..f35d90967 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -45,6 +45,7 @@ class LoginScreenTest : BaseComposeTest() { every { startCustomTabsActivity(any()) } returns Unit } private var onNavigateBackCalled = false + private var onNavigateToMasterPasswordHintCalled = false private var onNavigateToEnterpriseSignOnCalled = false private var onNavigateToLoginWithDeviceCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() @@ -59,6 +60,7 @@ class LoginScreenTest : BaseComposeTest() { composeTestRule.setContent { LoginScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true }, onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true }, onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true }, viewModel = viewModel, @@ -279,6 +281,12 @@ class LoginScreenTest : BaseComposeTest() { verify { intentManager.startCustomTabsActivity(mockUri) } } + @Test + fun `NavigateToMasterPasswordHint should call onNavigateToMasterPasswordHint`() { + mutableEventFlow.tryEmit(LoginEvent.NavigateToMasterPasswordHint("email")) + assertTrue(onNavigateToMasterPasswordHintCalled) + } + @Test fun `NavigateToEnterpriseSignOn should call onNavigateToEnterpriseSignOn`() { mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index dba7d3585..3f63f8432 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -336,13 +336,13 @@ class LoginViewModelTest : BaseViewModelTest() { } @Test - fun `MasterPasswordHintClick should emit ShowToast`() = runTest { + fun `MasterPasswordHintClick should emit NavigateToMasterPasswordHint`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend(LoginAction.MasterPasswordHintClick) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals( - LoginEvent.ShowToast("Not yet implemented."), + LoginEvent.NavigateToMasterPasswordHint("test@gmail.com"), awaitItem(), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt new file mode 100644 index 000000000..2f2fd7a42 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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", + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt new file mode 100644 index 000000000..76e6833e8 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordhint/MasterPasswordHintViewModelTest.kt @@ -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", +)