BIT-150: Add more comprehensive list of settings rows. (#155)

Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
Brian Yencho 2023-10-25 09:59:49 -05:00 committed by Álison Fernandes
parent 207bed42ed
commit 8bdda9bffd
5 changed files with 287 additions and 34 deletions

View file

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

View file

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

View 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>

View file

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

View file

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