BIT-142: setup create account screen (#15)

This commit is contained in:
Andrew Haisting 2023-08-29 14:20:02 -05:00 committed by Álison Fernandes
parent 17d5475d0f
commit a9aa2ea730
10 changed files with 271 additions and 1 deletions

View file

@ -63,6 +63,13 @@ abstract class BaseViewModel<S, E, A>(
*/ */
protected abstract fun handleAction(action: A): Unit 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. * Helper method for sending an internal action.
*/ */

View file

@ -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)
},
)
}

View file

@ -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()
}
}

View file

@ -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))
}
}

View file

@ -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<CreateAccountState, CreateAccountEvent, CreateAccountAction>(
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()
}

View file

@ -12,6 +12,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.ui.components.PlaceholderComposable import com.x8bit.bitwarden.ui.components.PlaceholderComposable
import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountScreen
/** /**
* Controls root level [NavHost] for the app. * Controls root level [NavHost] for the app.
@ -92,7 +93,7 @@ private const val LoginRoute = "login"
*/ */
private fun NavGraphBuilder.loginDestinations() { private fun NavGraphBuilder.loginDestinations() {
composable(LoginRoute) { composable(LoginRoute) {
PlaceholderComposable(text = "Login") CreateAccountScreen()
} }
} }

View file

@ -1,3 +1,12 @@
<resources> <resources>
<!--Common-->
<string name="app_name">Bitwarden</string> <string name="app_name">Bitwarden</string>
<!--Create Account Screen-->
<string name="title_create_account">Create Account</string>
<string name="input_label_email">Email address</string>
<string name="input_label_master_password">Master password</string>
<string name="input_label_re_type_master_password">Re-type master password</string>
<string name="input_label_master_password_hint">Master password hint (optional)</string>
<string name="button_submit">SUBMIT</string>
</resources> </resources>

View file

@ -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<CreateAccountViewModel>(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) }
}
}

View file

@ -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)
}
}
}

View file

@ -3,6 +3,7 @@
## Contents ## Contents
* [Style : Kotlin](#style--kotlin) * [Style : Kotlin](#style--kotlin)
* [Style : ViewModels](#style--view-models)
* [Best Practices : Kotlin](#best-practices--kotlin) * [Best Practices : Kotlin](#best-practices--kotlin)
* [Best Practices : Jetpack Compose](#best-practices--jetpack-compose) * [Best Practices : Jetpack Compose](#best-practices--jetpack-compose)
@ -445,6 +446,9 @@ val data = databaseDataSource.getData(id)
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 ## Best Practices : Kotlin