From dd2ad70b5244b33ef1e8a59373762b3f7138dfe2 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:05:51 -0500 Subject: [PATCH] BIT-289 Remove deprecated BitwardenTextField (#53) --- .../createaccount/CreateAccountScreen.kt | 35 ++++++-- .../createaccount/CreateAccountViewModel.kt | 86 +++++++++++++++++-- .../platform/components/BitwardenTextField.kt | 37 -------- .../createaccount/CreateAccountScreenTest.kt | 80 ++++++++++++++++- .../CreateAccountViewModelTest.kt | 75 +++++++++++++++- 5 files changed, 261 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt index a0b5fe05c..53a1691d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt @@ -12,6 +12,8 @@ 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.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -19,16 +21,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R +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 +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.BitwardenTextField /** * Top level composable for the create account screen. */ +@Suppress("LongMethod") @Composable fun CreateAccountScreen( viewModel: CreateAccountViewModel = hiltViewModel(), ) { + val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current EventsEffect(viewModel) { event -> when (event) { @@ -61,7 +70,7 @@ fun CreateAccountScreen( Text( modifier = Modifier .clickable { - viewModel.trySendAction(CreateAccountAction.SubmitClick) + viewModel.trySendAction(SubmitClick) } .padding(16.dp), text = stringResource(id = R.string.submit), @@ -69,9 +78,25 @@ fun CreateAccountScreen( style = MaterialTheme.typography.bodyMedium, ) } - BitwardenTextField(label = stringResource(id = R.string.email_address)) - BitwardenTextField(label = stringResource(id = R.string.master_password)) - BitwardenTextField(label = stringResource(id = R.string.retype_master_password)) - BitwardenTextField(label = stringResource(id = R.string.master_password_hint)) + BitwardenTextField( + label = stringResource(id = R.string.email_address), + value = state.emailInput, + onValueChange = { viewModel.trySendAction(EmailInputChange(it)) }, + ) + BitwardenTextField( + label = stringResource(id = R.string.master_password), + value = state.passwordInput, + onValueChange = { viewModel.trySendAction(PasswordInputChange(it)) }, + ) + BitwardenTextField( + label = stringResource(id = R.string.retype_master_password), + value = state.confirmPasswordInput, + onValueChange = { viewModel.trySendAction(ConfirmPasswordInputChange(it)) }, + ) + BitwardenTextField( + label = stringResource(id = R.string.master_password_hint), + value = state.passwordHintInput, + onValueChange = { viewModel.trySendAction(PasswordHintChange(it)) }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 5e2c32f61..34fec1a43 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -1,24 +1,72 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +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 +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.BaseViewModel 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" + /** * Models logic for the create account screen. */ @HiltViewModel -class CreateAccountViewModel @Inject constructor() : - BaseViewModel( - initialState = CreateAccountState, - ) { +class CreateAccountViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: CreateAccountState( + emailInput = "", + passwordInput = "", + confirmPasswordInput = "", + passwordHintInput = "", + ), +) { + + init { + // As state updates, write to saved state handle: + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } override fun handleAction(action: CreateAccountAction) { when (action) { - is CreateAccountAction.SubmitClick -> handleSubmitClick() + is SubmitClick -> handleSubmitClick() + is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action) + is EmailInputChange -> handleEmailInputChanged(action) + is PasswordHintChange -> handlePasswordHintChanged(action) + is PasswordInputChange -> handlePasswordInputChanged(action) } } + private fun handleEmailInputChanged(action: EmailInputChange) { + mutableStateFlow.update { it.copy(emailInput = action.input) } + } + + private fun handlePasswordHintChanged(action: PasswordHintChange) { + mutableStateFlow.update { it.copy(passwordHintInput = action.input) } + } + + private fun handlePasswordInputChanged(action: PasswordInputChange) { + mutableStateFlow.update { it.copy(passwordInput = action.input) } + } + + private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) { + mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) } + } + private fun handleSubmitClick() { sendEvent(CreateAccountEvent.ShowToast("TODO: Handle Submit Click")) } @@ -27,7 +75,13 @@ class CreateAccountViewModel @Inject constructor() : /** * UI state for the create account screen. */ -data object CreateAccountState +@Parcelize +data class CreateAccountState( + val emailInput: String, + val passwordInput: String, + val confirmPasswordInput: String, + val passwordHintInput: String, +) : Parcelable /** * Models events for the create account screen. @@ -48,4 +102,24 @@ sealed class CreateAccountAction { * User clicked submit. */ data object SubmitClick : CreateAccountAction() + + /** + * Email input changed. + */ + data class EmailInputChange(val input: String) : CreateAccountAction() + + /** + * Password input changed. + */ + data class PasswordInputChange(val input: String) : CreateAccountAction() + + /** + * Confirm password input changed. + */ + data class ConfirmPasswordInputChange(val input: String) : CreateAccountAction() + + /** + * Password hint input changed. + */ + data class PasswordHintChange(val input: String) : CreateAccountAction() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt index e6ea3b22d..ceba0de82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt @@ -5,46 +5,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -/** - * Component that allows the user to input text. This composable will manage the state of - * the user's input. - * - * @param label label for the text field. - * @param initialValue initial input text. - * @param onTextChange callback that is triggered when the input of the text field changes. - * - * TODO: remove deprecated version: BIT-289 - */ -@Deprecated( - message = "Use overloaded BitwardenTextField that takes an input instead of an initialText.", -) -@Composable -fun BitwardenTextField( - label: String, - initialValue: String = "", - onTextChange: (String) -> Unit = {}, -) { - var input by remember { mutableStateOf(initialValue) } - TextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - label = { Text(text = label) }, - value = input, - onValueChange = { - input = it - onTextChange.invoke(it) - }, - ) -} - /** * Component that allows the user to input text. This composable will manage the state of * the user's input. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt index 956cb7cfe..6e9cdd252 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt @@ -2,10 +2,17 @@ package com.x8bit.bitwarden.ui.auth.feature.createaccount 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.ConfirmPasswordInputChange +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.BaseComposeTest import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.junit.Test @@ -14,13 +21,82 @@ class CreateAccountScreenTest : BaseComposeTest() { @Test fun `submit click should send SubmitClick action`() { val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) every { eventFlow } returns emptyFlow() - every { trySendAction(CreateAccountAction.SubmitClick) } returns Unit + every { trySendAction(SubmitClick) } returns Unit } composeTestRule.setContent { CreateAccountScreen(viewModel) } composeTestRule.onNodeWithText("Submit").performClick() - verify { viewModel.trySendAction(CreateAccountAction.SubmitClick) } + verify { viewModel.trySendAction(SubmitClick) } + } + + @Test + fun `email input change should send EmailInputChange action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(EmailInputChange("input")) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(viewModel) + } + composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) } + } + + @Test + fun `password input change should send PasswordInputChange action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(PasswordInputChange("input")) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(viewModel) + } + composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) } + } + + @Test + fun `confirm password input change should send ConfirmPasswordInputChange action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(ConfirmPasswordInputChange("input")) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(viewModel) + } + composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) } + } + + @Test + fun `password hint input change should send PasswordHintChange action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + every { eventFlow } returns emptyFlow() + every { trySendAction(PasswordHintChange("input")) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(viewModel) + } + composeTestRule + .onNodeWithText("Master password hint (optional)") + .performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(PasswordHintChange(TEST_INPUT)) } + } + + companion object { + private const val TEST_INPUT = "input" + private val DEFAULT_STATE = CreateAccountState( + emailInput = "", + passwordInput = "", + confirmPasswordInput = "", + passwordHintInput = "", + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index 0f75bff39..ea2d85b2d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -1,18 +1,89 @@ 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.ConfirmPasswordInputChange +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.BaseViewModelTest import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class CreateAccountViewModelTest : BaseViewModelTest() { + @Test + fun `initial state should be correct`() { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `initial state should pull from saved state handle when present`() { + val savedState = CreateAccountState( + emailInput = "email", + passwordInput = "password", + confirmPasswordInput = "confirmPassword", + passwordHintInput = "hint", + ) + val handle = SavedStateHandle(mapOf("state" to savedState)) + val viewModel = CreateAccountViewModel(handle) + assertEquals(savedState, viewModel.stateFlow.value) + } + @Test fun `SubmitClick should emit ShowToast`() = runTest { - val viewModel = CreateAccountViewModel() + val viewModel = CreateAccountViewModel(SavedStateHandle()) viewModel.eventFlow.test { - viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.actionChannel.trySend(SubmitClick) assert(awaitItem() is CreateAccountEvent.ShowToast) } } + + @Test + fun `ConfirmPasswordInputChange update passwordInput`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.actionChannel.trySend(ConfirmPasswordInputChange("input")) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = "input"), awaitItem()) + } + } + + @Test + fun `EmailInputChange update passwordInput`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.actionChannel.trySend(EmailInputChange("input")) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(emailInput = "input"), awaitItem()) + } + } + + @Test + fun `PasswordHintChange update passwordInput`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.actionChannel.trySend(PasswordHintChange("input")) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(passwordHintInput = "input"), awaitItem()) + } + } + + @Test + fun `PasswordInputChange update passwordInput`() = runTest { + val viewModel = CreateAccountViewModel(SavedStateHandle()) + viewModel.actionChannel.trySend(PasswordInputChange("input")) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem()) + } + } + + companion object { + private val DEFAULT_STATE = CreateAccountState( + passwordInput = "", + emailInput = "", + confirmPasswordInput = "", + passwordHintInput = "", + ) + } }