diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt index d0a22fc31c..e326462063 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt @@ -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 }, + ) + }, + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt index a86b705d44..b28e9408d5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt @@ -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( - initialState = Unit, - ) { +class AppearanceViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + 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() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt index 3342f24507..e02d400158 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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 { - 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, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt index 1aa7a28268..51f53c73ce 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt @@ -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, + ) + } }