mirror of
https://github.com/bitwarden/android.git
synced 2024-11-21 17:05:44 +03:00
BIT-142: setup create account screen (#15)
This commit is contained in:
parent
17d5475d0f
commit
a9aa2ea730
10 changed files with 271 additions and 1 deletions
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue