diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt index a6cdeda7e..500ffc0ae 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt @@ -3,26 +3,40 @@ package com.x8bit.bitwarden.ui.platform.feature.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R 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.asText import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Displays the settings screen. @@ -38,21 +52,35 @@ fun SettingsScreen( SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke() } } - Column( - Modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surface), - ) { - BitwardenMediumTopAppBar( - title = stringResource(id = R.string.settings), - scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), - ) - SettingsRow( - text = R.string.account.asText(), - onClick = remember(viewModel) { - { viewModel.trySendAction(SettingsAction.AccountSecurityClick) } - }, - ) + + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + topBar = { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.settings), + 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( + text = it.text, + onClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.SettingsClick(it)) } + }, + ) + } + } } } @@ -61,17 +89,61 @@ private fun SettingsRow( text: Text, onClick: () -> Unit, ) { - Text( + Box( + contentAlignment = Alignment.BottomCenter, modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(color = MaterialTheme.colorScheme.primary), onClick = onClick, + ), + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = text(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) - .padding(horizontal = 16.dp, vertical = 16.dp) - .fillMaxWidth(), - text = text(), - style = MaterialTheme.typography.bodyLarge, - 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 = { }, + ) + } + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt index d6645be08..8fc9c9cf4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -1,6 +1,10 @@ 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.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -12,11 +16,35 @@ class SettingsViewModel @Inject constructor() : BaseViewModel handleAccountSecurityClick() + is SettingsAction.SettingsClick -> handleAccountSecurityClick(action) } - private fun handleAccountSecurityClick() { - sendEvent(SettingsEvent.NavigateAccountSecurity) + private fun handleAccountSecurityClick(action: SettingsAction.SettingsClick) { + when (action.settings) { + Settings.ACCOUNT_SECURITY -> { + 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 { /** - * 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()), } diff --git a/app/src/main/res/drawable/ic_navigate_next.xml b/app/src/main/res/drawable/ic_navigate_next.xml new file mode 100644 index 000000000..7ff13e39f --- /dev/null +++ b/app/src/main/res/drawable/ic_navigate_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt index 8cc2549db..08a39731f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt @@ -13,10 +13,10 @@ import org.junit.Test class SettingsScreenTest : BaseComposeTest() { @Test - fun `on account row click should emit AccountSecurityClick`() { + fun `on about row click should emit SettingsClick`() { val viewModel = mockk { every { eventFlow } returns emptyFlow() - every { trySendAction(SettingsAction.AccountSecurityClick) } returns Unit + every { trySendAction(SettingsAction.SettingsClick(Settings.ABOUT)) } returns Unit } composeTestRule.setContent { SettingsScreen( @@ -24,8 +24,90 @@ class SettingsScreenTest : BaseComposeTest() { onNavigateToAccountSecurity = { }, ) } - composeTestRule.onNodeWithText("Account").performClick() - verify { viewModel.trySendAction(SettingsAction.AccountSecurityClick) } + composeTestRule.onNodeWithText("About").performClick() + verify { viewModel.trySendAction(SettingsAction.SettingsClick(Settings.ABOUT)) } + } + + @Test + fun `on account security row click should emit SettingsClick`() { + val viewModel = mockk { + 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 { + 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 { + 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 { + 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 { + 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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt index b695225ea..2c2443a8c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -9,11 +9,56 @@ import org.junit.jupiter.api.Test class SettingsViewModelTest : BaseViewModelTest() { @Test - fun `on AccountSecurityClick should emit NavigateAccountSecurity`() = runTest { + fun `on SettingsClick with ABOUT should emit nothing`() = runTest { val viewModel = SettingsViewModel() 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()) } } + + @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() + } + } }