diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt index ba72f6499..33d050e42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/base/BaseViewModel.kt @@ -63,6 +63,13 @@ abstract class BaseViewModel( */ protected abstract fun handleAction(action: A): Unit + /** + * Convenience method for sending an action to the [actionChannel]. + */ + fun trySendAction(action: A) { + actionChannel.trySend(action) + } + /** * Helper method for sending an internal action. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/components/BitwardenTextField.kt new file mode 100644 index 000000000..8662a4abc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/components/BitwardenTextField.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +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. + */ +@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) + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountNavigation.kt new file mode 100644 index 000000000..b8fcfe1f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountNavigation.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.ui.feature.createaccount + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +private const val CREATE_ACCOUNT_ROUTE = "create_account" + +/** + * Navigate to the create account screen. + */ +fun NavController.navigateToCreateAccount(navOptions: NavOptions? = null) { + this.navigate(CREATE_ACCOUNT_ROUTE, navOptions) +} + +/** + * Add the create account screen to the nav graph. + */ +fun NavGraphBuilder.createAccountDestinations() { + composable(route = CREATE_ACCOUNT_ROUTE) { + CreateAccountScreen() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountScreen.kt new file mode 100644 index 000000000..83b3621bc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountScreen.kt @@ -0,0 +1,83 @@ +package com.x8bit.bitwarden.ui.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.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.components.BitwardenTextField +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Top level composable for the create account screen. + */ +@Composable +fun CreateAccountScreen( + viewModel: CreateAccountViewModel = viewModel(), +) { + val context = LocalContext.current + LaunchedEffect(key1 = Unit) { + viewModel.eventFlow + .onEach { event -> + when (event) { + is CreateAccountEvent.ShowToast -> { + Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show() + } + } + } + .launchIn(this) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(16.dp), + text = stringResource(id = R.string.title_create_account), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + ) + Text( + modifier = Modifier + .clickable { + viewModel.trySendAction(CreateAccountAction.SubmitClick) + } + .padding(16.dp), + text = stringResource(id = R.string.button_submit), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodyMedium, + ) + } + BitwardenTextField(label = stringResource(id = R.string.input_label_email)) + BitwardenTextField(label = stringResource(id = R.string.input_label_master_password)) + BitwardenTextField(label = stringResource(id = R.string.input_label_re_type_master_password)) + BitwardenTextField(label = stringResource(id = R.string.input_label_master_password_hint)) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountViewModel.kt new file mode 100644 index 000000000..3724ef53a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/createaccount/CreateAccountViewModel.kt @@ -0,0 +1,51 @@ +package com.x8bit.bitwarden.ui.feature.createaccount + +import com.x8bit.bitwarden.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Models logic for the create account screen. + */ +@HiltViewModel +class CreateAccountViewModel @Inject constructor() : + BaseViewModel( + initialState = CreateAccountState, + ) { + + override fun handleAction(action: CreateAccountAction) { + when (action) { + is CreateAccountAction.SubmitClick -> handleSubmitClick() + } + } + + private fun handleSubmitClick() { + sendEvent(CreateAccountEvent.ShowToast("TODO: Handle Submit Click")) + } +} + +/** + * UI state for the create account screen. + */ +data object CreateAccountState + +/** + * Models events for the create account screen. + */ +sealed class CreateAccountEvent { + + /** + * Placeholder event for showing a toast. Can be removed once there are real events. + */ + data class ShowToast(val text: String) : CreateAccountEvent() +} + +/** + * Models actions for the create account screen. + */ +sealed class CreateAccountAction { + /** + * User clicked submit. + */ + data object SubmitClick : CreateAccountAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt index 79066e1e4..106b15f5d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt @@ -12,6 +12,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.x8bit.bitwarden.ui.components.PlaceholderComposable +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountScreen /** * Controls root level [NavHost] for the app. @@ -92,7 +93,7 @@ private const val LoginRoute = "login" */ private fun NavGraphBuilder.loginDestinations() { composable(LoginRoute) { - PlaceholderComposable(text = "Login") + CreateAccountScreen() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 193af8b68..aabd9b0c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,12 @@ + Bitwarden + + + Create Account + Email address + Master password + Re-type master password + Master password hint (optional) + SUBMIT diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountScreenTest.kt new file mode 100644 index 000000000..6e0d393ba --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountScreenTest.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.example.ui.feature.createaccount + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.example.ui.BaseComposeTest +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountAction +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountScreen +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountViewModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Test + +class CreateAccountScreenTest : BaseComposeTest() { + + @Test + fun `on submit click should send SubmitClick action`() { + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { trySendAction(CreateAccountAction.SubmitClick) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen(viewModel) + } + composeTestRule.onNodeWithText("SUBMIT").performClick() + verify { viewModel.trySendAction(CreateAccountAction.SubmitClick) } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountViewModelTest.kt new file mode 100644 index 000000000..aa66dc5e7 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/createaccount/CreateAccountViewModelTest.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.example.ui.feature.createaccount + +import app.cash.turbine.test +import com.x8bit.bitwarden.example.ui.BaseViewModelTest +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountAction +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountEvent +import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountViewModel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class CreateAccountViewModelTest : BaseViewModelTest() { + + @Test + fun `on SubmitClick should emit ShowToast`() = runTest { + val viewModel = CreateAccountViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + assert(awaitItem() is CreateAccountEvent.ShowToast) + } + } +} diff --git a/docs/STYLE_AND_BEST_PRACTICES.md b/docs/STYLE_AND_BEST_PRACTICES.md index b9fe470e9..a43afa8ec 100644 --- a/docs/STYLE_AND_BEST_PRACTICES.md +++ b/docs/STYLE_AND_BEST_PRACTICES.md @@ -3,6 +3,7 @@ ## Contents * [Style : Kotlin](#style--kotlin) +* [Style : ViewModels](#style--view-models) * [Best Practices : Kotlin](#best-practices--kotlin) * [Best Practices : Jetpack Compose](#best-practices--jetpack-compose) @@ -445,6 +446,9 @@ val data = databaseDataSource.getData(id) val data = databaseDataSource.getData(id) ``` +## Style : ViewModels + +- Private functions that handle actions should be prefixed with "handle" and suffixed with the name of the action. (ex: `handleSubmitClick`) ## Best Practices : Kotlin