BIT-929: Add UI for Appearance screen (#478)

This commit is contained in:
Caleb Derosier 2024-01-04 13:15:47 -07:00 committed by Álison Fernandes
parent 84bb3bcdb2
commit 10bad26c95
4 changed files with 379 additions and 25 deletions

View file

@ -2,33 +2,47 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.compose.foundation.layout.Column
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
/**
* Displays the appearance screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppearanceScreen(
onNavigateBack: () -> Unit,
viewModel: AppearanceViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AppearanceEvent.NavigateBack -> onNavigateBack.invoke()
@ -58,7 +72,117 @@ fun AppearanceScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
// TODO: BIT-929 Display Appearance UI
LanguageSelectionRow(
currentSelection = state.language,
onThemeSelection = remember(viewModel) {
{ viewModel.trySendAction(AppearanceAction.LanguageChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
)
ThemeSelectionRow(
currentSelection = state.theme,
onThemeSelection = remember(viewModel) {
{ viewModel.trySendAction(AppearanceAction.ThemeChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenWideSwitch(
label = stringResource(id = R.string.show_website_icons),
description = stringResource(id = R.string.show_website_icons_description),
isChecked = state.showWebsiteIcons,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AppearanceAction.ShowWebsiteIconsToggle(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
}
@Composable
private fun LanguageSelectionRow(
currentSelection: AppearanceState.Language,
onThemeSelection: (AppearanceState.Language) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowLanguageSelectionDialog by remember { mutableStateOf(false) }
BitwardenTextRow(
text = stringResource(id = R.string.language),
description = stringResource(id = R.string.language_change_requires_app_restart),
onClick = { shouldShowLanguageSelectionDialog = true },
modifier = modifier,
) {
Text(
text = currentSelection.text(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (shouldShowLanguageSelectionDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.language),
onDismissRequest = { shouldShowLanguageSelectionDialog = false },
) {
AppearanceState.Language.entries.forEach { option ->
BitwardenSelectionRow(
text = option.text,
isSelected = option == currentSelection,
onClick = {
shouldShowLanguageSelectionDialog = false
onThemeSelection(
AppearanceState.Language.entries.first { it == option },
)
},
)
}
}
}
}
@Composable
private fun ThemeSelectionRow(
currentSelection: AppearanceState.Theme,
onThemeSelection: (AppearanceState.Theme) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowThemeSelectionDialog by remember { mutableStateOf(false) }
BitwardenTextRow(
text = stringResource(id = R.string.theme),
description = stringResource(id = R.string.theme_description),
onClick = { shouldShowThemeSelectionDialog = true },
modifier = modifier,
) {
Text(
text = currentSelection.text(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (shouldShowThemeSelectionDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.theme),
onDismissRequest = { shouldShowThemeSelectionDialog = false },
) {
AppearanceState.Theme.entries.forEach { option ->
BitwardenSelectionRow(
text = option.text,
isSelected = option == currentSelection,
onClick = {
shouldShowThemeSelectionDialog = false
onThemeSelection(
AppearanceState.Theme.entries.first { it == option },
)
},
)
}
}
}
}

View file

@ -1,19 +1,85 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
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 kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the appearance screen.
*/
@HiltViewModel
class AppearanceViewModel @Inject constructor() :
BaseViewModel<Unit, AppearanceEvent, AppearanceAction>(
initialState = Unit,
) {
class AppearanceViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AppearanceState, AppearanceEvent, AppearanceAction>(
initialState = savedStateHandle[KEY_STATE]
?: AppearanceState(
language = AppearanceState.Language.DEFAULT,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
),
) {
override fun handleAction(action: AppearanceAction): Unit = when (action) {
AppearanceAction.BackClick -> sendEvent(AppearanceEvent.NavigateBack)
AppearanceAction.BackClick -> handleBackClicked()
is AppearanceAction.LanguageChange -> handleLanguageChanged(action)
is AppearanceAction.ShowWebsiteIconsToggle -> handleShowWebsiteIconsToggled(action)
is AppearanceAction.ThemeChange -> handleThemeChanged(action)
}
private fun handleBackClicked() {
sendEvent(AppearanceEvent.NavigateBack)
}
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
// TODO: BIT-1328 implement language selection support
mutableStateFlow.update { it.copy(language = action.newLanguage) }
}
private fun handleShowWebsiteIconsToggled(action: AppearanceAction.ShowWebsiteIconsToggle) {
// TODO: BIT-541 add website icon support
mutableStateFlow.update { it.copy(showWebsiteIcons = action.newValue) }
}
private fun handleThemeChanged(action: AppearanceAction.ThemeChange) {
// TODO: BIT-1327 add theme support
mutableStateFlow.update { it.copy(theme = action.newTheme) }
}
}
/**
* Models state of the Appearance screen.
*/
@Parcelize
data class AppearanceState(
val language: Language,
val showWebsiteIcons: Boolean,
val theme: Theme,
) : Parcelable {
/**
* Represents the languages supported by the app.
*
* TODO BIT-1328 populate values
*/
enum class Language(val text: Text) {
DEFAULT(text = R.string.default_system.asText()),
ENGLISH(text = "English".asText()),
}
/**
* Represents the theme options the user can set.
*/
enum class Theme(val text: Text) {
DEFAULT(text = R.string.default_system.asText()),
DARK(text = R.string.dark.asText()),
LIGHT(text = R.string.light.asText()),
}
}
@ -35,4 +101,25 @@ sealed class AppearanceAction {
* User clicked back button.
*/
data object BackClick : AppearanceAction()
/**
* Indicates that the user changed the Language.
*/
data class LanguageChange(
val newLanguage: AppearanceState.Language,
) : AppearanceAction()
/**
* Indicates that the user toggled the Show Website Icons switch to [newValue].
*/
data class ShowWebsiteIconsToggle(
val newValue: Boolean,
) : AppearanceAction()
/**
* Indicates that the user selected a new theme.
*/
data class ThemeChange(
val newTheme: AppearanceState.Theme,
) : AppearanceAction()
}

View file

@ -1,46 +1,110 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AppearanceScreenTest : BaseComposeTest() {
@Test
fun `on back click should send BackClick`() {
val viewModel: AppearanceViewModel = mockk {
every { eventFlow } returns emptyFlow()
every { trySendAction(AppearanceAction.BackClick) } returns Unit
}
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<AppearanceEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<AppearanceViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
AppearanceScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel,
onNavigateBack = { },
)
}
}
@Test
fun `on back click should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(AppearanceAction.BackClick) }
}
@Test
fun `on NavigateAbout should call onNavigateToAbout`() {
var haveCalledNavigateBack = false
val viewModel = mockk<AppearanceViewModel> {
every { eventFlow } returns flowOf(AppearanceEvent.NavigateBack)
}
composeTestRule.setContent {
AppearanceScreen(
viewModel = viewModel,
onNavigateBack = { haveCalledNavigateBack = true },
fun `on language row click should display language selection dialog`() {
composeTestRule.onNodeWithText("Language").performClick()
composeTestRule
.onAllNodesWithText("Language")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on language selection dialog item click should send LanguageChange`() {
composeTestRule.onNodeWithText("Language").performClick()
composeTestRule.onNodeWithText("English").performClick()
verify {
viewModel.trySendAction(
AppearanceAction.LanguageChange(
newLanguage = AppearanceState.Language.ENGLISH,
),
)
}
}
@Test
fun `on theme row click should display theme selection dialog`() {
composeTestRule.onNodeWithText("Theme").performClick()
composeTestRule
.onAllNodesWithText("Theme")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on theme selection dialog item click should send ThemeChange`() {
composeTestRule.onNodeWithText("Theme").performClick()
composeTestRule.onNodeWithText("Dark").performClick()
verify {
viewModel.trySendAction(
AppearanceAction.ThemeChange(
newTheme = AppearanceState.Theme.DARK,
),
)
}
}
@Test
fun `on show website icons row click should send ShowWebsiteIconsToggled`() {
composeTestRule.onNodeWithText("Show website icons").performClick()
verify { viewModel.trySendAction(AppearanceAction.ShowWebsiteIconsToggle(true)) }
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(AppearanceEvent.NavigateBack)
assertTrue(haveCalledNavigateBack)
}
}
private val DEFAULT_STATE = AppearanceState(
language = AppearanceState.Language.DEFAULT,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
@ -7,13 +8,91 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AppearanceViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = AppearanceViewModel()
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AppearanceAction.BackClick)
assertEquals(AppearanceEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on LanguageChange should update state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
AppearanceAction.LanguageChange(AppearanceState.Language.ENGLISH),
)
assertEquals(
DEFAULT_STATE.copy(language = AppearanceState.Language.ENGLISH),
awaitItem(),
)
}
}
@Test
fun `on ShowWebsiteIconsToggle should update value in state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(AppearanceAction.ShowWebsiteIconsToggle(true))
assertEquals(
DEFAULT_STATE.copy(showWebsiteIcons = true),
awaitItem(),
)
}
}
@Test
fun `on ThemeChange should update state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(AppearanceAction.ThemeChange(AppearanceState.Theme.DARK))
assertEquals(
DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK),
awaitItem(),
)
}
}
private fun createViewModel(
state: AppearanceState? = null,
) = AppearanceViewModel(
savedStateHandle = SavedStateHandle().apply {
set("state", state)
},
)
companion object {
private val DEFAULT_STATE = AppearanceState(
language = AppearanceState.Language.DEFAULT,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
)
}
}