mirror of
https://github.com/bitwarden/android.git
synced 2024-11-28 13:58:51 +03:00
BIT-150: Add more comprehensive list of settings rows. (#155)
Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
parent
207bed42ed
commit
8bdda9bffd
5 changed files with 287 additions and 34 deletions
|
@ -3,26 +3,40 @@ package com.x8bit.bitwarden.ui.platform.feature.settings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
|
||||||
|
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the settings screen.
|
* Displays the settings screen.
|
||||||
|
@ -38,22 +52,36 @@ fun SettingsScreen(
|
||||||
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke()
|
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
|
||||||
Modifier
|
val scrollBehavior =
|
||||||
.fillMaxSize()
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
.background(color = MaterialTheme.colorScheme.surface),
|
|
||||||
) {
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
BitwardenMediumTopAppBar(
|
BitwardenMediumTopAppBar(
|
||||||
title = stringResource(id = R.string.settings),
|
title = stringResource(id = R.string.settings),
|
||||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(state = rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Settings.values().forEach {
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
text = R.string.account.asText(),
|
text = it.text,
|
||||||
onClick = remember(viewModel) {
|
onClick = remember(viewModel) {
|
||||||
{ viewModel.trySendAction(SettingsAction.AccountSecurityClick) }
|
{ viewModel.trySendAction(SettingsAction.SettingsClick(it)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -61,17 +89,61 @@ private fun SettingsRow(
|
||||||
text: Text,
|
text: Text,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Text(
|
Box(
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
|
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
),
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.defaultMinSize(minHeight = 56.dp)
|
||||||
|
.padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.weight(1f),
|
||||||
text = text(),
|
text = text(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_navigate_next),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun SettingsRows_preview() {
|
||||||
|
BitwardenTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
Settings.values().forEach {
|
||||||
|
SettingsRow(
|
||||||
|
text = it.text,
|
||||||
|
onClick = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.settings
|
package com.x8bit.bitwarden.ui.platform.feature.settings
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -12,12 +16,36 @@ class SettingsViewModel @Inject constructor() : BaseViewModel<Unit, SettingsEven
|
||||||
initialState = Unit,
|
initialState = Unit,
|
||||||
) {
|
) {
|
||||||
override fun handleAction(action: SettingsAction): Unit = when (action) {
|
override fun handleAction(action: SettingsAction): Unit = when (action) {
|
||||||
SettingsAction.AccountSecurityClick -> handleAccountSecurityClick()
|
is SettingsAction.SettingsClick -> handleAccountSecurityClick(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleAccountSecurityClick() {
|
private fun handleAccountSecurityClick(action: SettingsAction.SettingsClick) {
|
||||||
|
when (action.settings) {
|
||||||
|
Settings.ACCOUNT_SECURITY -> {
|
||||||
sendEvent(SettingsEvent.NavigateAccountSecurity)
|
sendEvent(SettingsEvent.NavigateAccountSecurity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Settings.AUTO_FILL -> {
|
||||||
|
// TODO: BIT-927 Launch auto-fill UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.VAULT -> {
|
||||||
|
// TODO: BIT-928 Launch vault UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.APPEARANCE -> {
|
||||||
|
// TODO: BIT-929 Launch appearance UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.OTHER -> {
|
||||||
|
// TODO: BIT-930 Launch other UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.ABOUT -> {
|
||||||
|
// TODO: BIT-931 Launch about UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,7 +63,24 @@ sealed class SettingsEvent {
|
||||||
*/
|
*/
|
||||||
sealed class SettingsAction {
|
sealed class SettingsAction {
|
||||||
/**
|
/**
|
||||||
* User clicked account security.
|
* User clicked a settings row.
|
||||||
*/
|
*/
|
||||||
data object AccountSecurityClick : SettingsAction()
|
data class SettingsClick(
|
||||||
|
val settings: Settings,
|
||||||
|
) : SettingsAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum representing the settings rows, such as "account security" or "vault".
|
||||||
|
*
|
||||||
|
* @property text The [Text] of the string that represents the label of each setting.
|
||||||
|
*/
|
||||||
|
// TODO: BIT-944 Missing correct resources for "Account Security", "Vault", and "Appearance".
|
||||||
|
enum class Settings(val text: Text) {
|
||||||
|
ACCOUNT_SECURITY(R.string.security.asText()),
|
||||||
|
AUTO_FILL(R.string.autofill.asText()),
|
||||||
|
VAULT(R.string.vaults.asText()),
|
||||||
|
APPEARANCE(R.string.language.asText()),
|
||||||
|
OTHER(R.string.other.asText()),
|
||||||
|
ABOUT(R.string.about.asText()),
|
||||||
}
|
}
|
||||||
|
|
9
app/src/main/res/drawable/ic_navigate_next.xml
Normal file
9
app/src/main/res/drawable/ic_navigate_next.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="20dp"
|
||||||
|
android:height="20dp"
|
||||||
|
android:viewportHeight="20"
|
||||||
|
android:viewportWidth="20">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1B1B1F"
|
||||||
|
android:pathData="M14.697,10.431C15.082,10.052 15.014,9.888 14.634,9.519C14.634,9.519 7.04,1.619 6.524,1.062C6.008,0.504 6.764,-0.522 7.536,0.317C8.309,1.157 15.759,8.887 15.759,8.887C16.301,9.553 16.301,10.461 15.759,11.126L7.644,19.589C6.792,20.527 5.843,19.68 6.628,18.859C9.647,15.7 14.697,10.431 14.697,10.431Z" />
|
||||||
|
</vector>
|
|
@ -13,10 +13,10 @@ import org.junit.Test
|
||||||
class SettingsScreenTest : BaseComposeTest() {
|
class SettingsScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on account row click should emit AccountSecurityClick`() {
|
fun `on about row click should emit SettingsClick`() {
|
||||||
val viewModel = mockk<SettingsViewModel> {
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
every { eventFlow } returns emptyFlow()
|
every { eventFlow } returns emptyFlow()
|
||||||
every { trySendAction(SettingsAction.AccountSecurityClick) } returns Unit
|
every { trySendAction(SettingsAction.SettingsClick(Settings.ABOUT)) } returns Unit
|
||||||
}
|
}
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
|
@ -24,8 +24,90 @@ class SettingsScreenTest : BaseComposeTest() {
|
||||||
onNavigateToAccountSecurity = { },
|
onNavigateToAccountSecurity = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composeTestRule.onNodeWithText("Account").performClick()
|
composeTestRule.onNodeWithText("About").performClick()
|
||||||
verify { viewModel.trySendAction(SettingsAction.AccountSecurityClick) }
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.ABOUT)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on account security row click should emit SettingsClick`() {
|
||||||
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
|
every { eventFlow } returns emptyFlow()
|
||||||
|
every {
|
||||||
|
trySendAction(SettingsAction.SettingsClick(Settings.ACCOUNT_SECURITY))
|
||||||
|
} returns Unit
|
||||||
|
}
|
||||||
|
composeTestRule.setContent {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToAccountSecurity = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Security").performClick()
|
||||||
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.ACCOUNT_SECURITY)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on appearance row click should emit SettingsClick`() {
|
||||||
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
|
every { eventFlow } returns emptyFlow()
|
||||||
|
every { trySendAction(SettingsAction.SettingsClick(Settings.APPEARANCE)) } returns Unit
|
||||||
|
}
|
||||||
|
composeTestRule.setContent {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToAccountSecurity = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Language").performClick()
|
||||||
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.APPEARANCE)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on auto-fill row click should emit SettingsClick`() {
|
||||||
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
|
every { eventFlow } returns emptyFlow()
|
||||||
|
every { trySendAction(SettingsAction.SettingsClick(Settings.AUTO_FILL)) } returns Unit
|
||||||
|
}
|
||||||
|
composeTestRule.setContent {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToAccountSecurity = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Auto-fill").performClick()
|
||||||
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.AUTO_FILL)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on other row click should emit SettingsClick`() {
|
||||||
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
|
every { eventFlow } returns emptyFlow()
|
||||||
|
every { trySendAction(SettingsAction.SettingsClick(Settings.OTHER)) } returns Unit
|
||||||
|
}
|
||||||
|
composeTestRule.setContent {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToAccountSecurity = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Other").performClick()
|
||||||
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.OTHER)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on vault row click should emit SettingsClick`() {
|
||||||
|
val viewModel = mockk<SettingsViewModel> {
|
||||||
|
every { eventFlow } returns emptyFlow()
|
||||||
|
every { trySendAction(SettingsAction.SettingsClick(Settings.VAULT)) } returns Unit
|
||||||
|
}
|
||||||
|
composeTestRule.setContent {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToAccountSecurity = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("Vaults").performClick()
|
||||||
|
verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.VAULT)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -9,11 +9,56 @@ import org.junit.jupiter.api.Test
|
||||||
class SettingsViewModelTest : BaseViewModelTest() {
|
class SettingsViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on AccountSecurityClick should emit NavigateAccountSecurity`() = runTest {
|
fun `on SettingsClick with ABOUT should emit nothing`() = runTest {
|
||||||
val viewModel = SettingsViewModel()
|
val viewModel = SettingsViewModel()
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.trySendAction(SettingsAction.AccountSecurityClick)
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.ABOUT))
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SettingsClick with ACCOUNT_SECURITY should emit NavigateAccountSecurity`() = runTest {
|
||||||
|
val viewModel = SettingsViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.ACCOUNT_SECURITY))
|
||||||
assertEquals(SettingsEvent.NavigateAccountSecurity, awaitItem())
|
assertEquals(SettingsEvent.NavigateAccountSecurity, awaitItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SettingsClick with APPEARANCE should emit nothing`() = runTest {
|
||||||
|
val viewModel = SettingsViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.APPEARANCE))
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SettingsClick with AUTO_FILL should emit nothing`() = runTest {
|
||||||
|
val viewModel = SettingsViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.AUTO_FILL))
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SettingsClick with OTHER should emit nothing`() = runTest {
|
||||||
|
val viewModel = SettingsViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.OTHER))
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on SettingsClick with VAULT should emit nothing`() = runTest {
|
||||||
|
val viewModel = SettingsViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(SettingsAction.SettingsClick(Settings.VAULT))
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue