From b24c2ba7e74ee4cb06896d8444ae4c58f5aecb0f Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Thu, 4 Jan 2024 19:24:12 -0700 Subject: [PATCH] BIT-930: Add UI for Other screen (#477) --- .../feature/settings/other/OtherScreen.kt | 164 +++++++++++++++++- .../feature/settings/other/OtherViewModel.kt | 109 +++++++++++- .../feature/settings/other/OtherScreenTest.kt | 132 +++++++++++--- .../settings/other/OtherViewModelTest.kt | 114 +++++++++++- 4 files changed, 492 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreen.kt index 1d62b9e54f..260c6e94b7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreen.kt @@ -1,34 +1,55 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height 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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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.components.BitwardenFilledTonalButton 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.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch /** * Displays the other screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun OtherScreen( onNavigateBack: () -> Unit, viewModel: OtherViewModel = hiltViewModel(), ) { + val state by viewModel.stateFlow.collectAsState() EventsEffect(viewModel = viewModel) { event -> when (event) { OtherEvent.NavigateBack -> onNavigateBack.invoke() @@ -58,7 +79,148 @@ fun OtherScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - // TODO: BIT-930 Display Other UI + BitwardenWideSwitch( + label = stringResource(id = R.string.enable_sync_on_refresh), + description = stringResource(id = R.string.enable_sync_on_refresh_description), + isChecked = state.allowSyncOnRefresh, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(OtherAction.AllowSyncToggle(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenFilledTonalButton( + onClick = remember(viewModel) { + { viewModel.trySendAction(OtherAction.SyncNowButtonClick) } + }, + label = stringResource(id = R.string.sync_now), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Text( + text = stringResource(id = R.string.last_sync), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, end = 2.dp), + ) + Text( + text = state.lastSyncTime, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + ClearClipboardFrequencyRow( + currentSelection = state.clearClipboardFrequency, + onFrequencySelection = remember(viewModel) { + { viewModel.trySendAction(OtherAction.ClearClipboardFrequencyChange(it)) } + }, + modifier = Modifier.fillMaxWidth(), + ) + + ScreenCaptureRow( + currentValue = state.allowScreenCapture, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } +} + +@Composable +private fun ScreenCaptureRow( + currentValue: Boolean, + onValueChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowScreenCaptureConfirmDialog by remember { mutableStateOf(false) } + + BitwardenWideSwitch( + label = stringResource(id = R.string.allow_screen_capture), + isChecked = currentValue, + onCheckedChange = { + if (currentValue) { + onValueChange(false) + } else { + shouldShowScreenCaptureConfirmDialog = true + } + }, + modifier = modifier, + ) + + if (shouldShowScreenCaptureConfirmDialog) { + BitwardenTwoButtonDialog( + title = stringResource(R.string.allow_screen_capture), + message = stringResource(R.string.are_you_sure_you_want_to_enable_screen_capture), + confirmButtonText = stringResource(R.string.yes), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onValueChange(true) + shouldShowScreenCaptureConfirmDialog = false + }, + onDismissClick = { shouldShowScreenCaptureConfirmDialog = false }, + onDismissRequest = { shouldShowScreenCaptureConfirmDialog = false }, + ) + } +} + +@Composable +private fun ClearClipboardFrequencyRow( + currentSelection: OtherState.ClearClipboardFrequency, + onFrequencySelection: (OtherState.ClearClipboardFrequency) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowClearClipboardDialog by remember { mutableStateOf(false) } + + BitwardenTextRow( + text = stringResource(id = R.string.clear_clipboard), + description = stringResource(id = R.string.clear_clipboard_description), + onClick = { shouldShowClearClipboardDialog = true }, + modifier = modifier, + ) { + Text( + text = currentSelection.text(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (shouldShowClearClipboardDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.clear_clipboard), + onDismissRequest = { shouldShowClearClipboardDialog = false }, + ) { + OtherState.ClearClipboardFrequency.entries.forEach { option -> + BitwardenSelectionRow( + text = option.text, + isSelected = option == currentSelection, + onClick = { + shouldShowClearClipboardDialog = false + onFrequencySelection( + OtherState.ClearClipboardFrequency.entries.first { it == option }, + ) + }, + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt index e15319589e..7b089472c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt @@ -1,18 +1,95 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.vault.repository.VaultRepository 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 other screen. */ @HiltViewModel -class OtherViewModel @Inject constructor() : BaseViewModel( - initialState = Unit, +class OtherViewModel @Inject constructor( + private val vaultRepo: VaultRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: OtherState( + allowScreenCapture = false, + allowSyncOnRefresh = false, + clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT, + lastSyncTime = "5/14/2023 4:52 PM", + ), ) { override fun handleAction(action: OtherAction): Unit = when (action) { - OtherAction.BackClick -> sendEvent(OtherEvent.NavigateBack) + is OtherAction.AllowScreenCaptureToggle -> handleAllowScreenCaptureToggled(action) + is OtherAction.AllowSyncToggle -> handleAllowSyncToggled(action) + OtherAction.BackClick -> handleBackClicked() + is OtherAction.ClearClipboardFrequencyChange -> handleClearClipboardFrequencyChanged(action) + OtherAction.SyncNowButtonClick -> handleSyncNowButtonClicked() + } + + private fun handleAllowScreenCaptureToggled(action: OtherAction.AllowScreenCaptureToggle) { + // TODO BIT-805 implement screen capture setting + mutableStateFlow.update { it.copy(allowScreenCapture = action.isScreenCaptureEnabled) } + } + + private fun handleAllowSyncToggled(action: OtherAction.AllowSyncToggle) { + // TODO BIT-461 hook up to pull-to-refresh feature + mutableStateFlow.update { it.copy(allowSyncOnRefresh = action.isSyncEnabled) } + } + + private fun handleBackClicked() { + sendEvent(OtherEvent.NavigateBack) + } + + private fun handleClearClipboardFrequencyChanged( + action: OtherAction.ClearClipboardFrequencyChange, + ) { + // TODO BIT-1283 implement clear clipboard setting + mutableStateFlow.update { + it.copy( + clearClipboardFrequency = action.clearClipboardFrequency, + ) + } + } + + private fun handleSyncNowButtonClicked() { + // TODO BIT-1282 add full support and visual feedback + vaultRepo.sync() + } +} + +/** + * Models the state of the Other screen. + */ +@Parcelize +data class OtherState( + val allowScreenCapture: Boolean, + val allowSyncOnRefresh: Boolean, + val clearClipboardFrequency: ClearClipboardFrequency, + val lastSyncTime: String, +) : Parcelable { + /** + * Represents the different frequencies with which the user clipboard can be cleared. + */ + enum class ClearClipboardFrequency(val text: Text) { + DEFAULT(text = R.string.never.asText()), + TEN_SECONDS(text = R.string.ten_seconds.asText()), + TWENTY_SECONDS(text = R.string.twenty_seconds.asText()), + THIRTY_SECONDS(text = R.string.thirty_seconds.asText()), + ONE_MINUTE(text = R.string.one_minute.asText()), + TWO_MINUTES(text = R.string.two_minutes.asText()), + FIVE_MINUTES(text = R.string.five_minutes.asText()), } } @@ -30,8 +107,34 @@ sealed class OtherEvent { * Models actions for the other screen. */ sealed class OtherAction { + /** + * Indicates that the user toggled the Allow screen capture switch to [isScreenCaptureEnabled]. + */ + data class AllowScreenCaptureToggle( + val isScreenCaptureEnabled: Boolean, + ) : OtherAction() + + /** + * Indicates that the user toggled the Allow sync on refresh switch to [isSyncEnabled]. + */ + data class AllowSyncToggle( + val isSyncEnabled: Boolean, + ) : OtherAction() + /** * User clicked back button. */ data object BackClick : OtherAction() + + /** + * Indicates that the user changed the clear clipboard frequency. + */ + data class ClearClipboardFrequencyChange( + val clearClipboardFrequency: OtherState.ClearClipboardFrequency, + ) : OtherAction() + + /** + * Indicates that the user clicked the Sync Now button. + */ + data object SyncNowButtonClick : OtherAction() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreenTest.kt index 9cad488600..69aa85b7ea 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherScreenTest.kt @@ -1,46 +1,134 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other +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 com.x8bit.bitwarden.ui.util.assertNoDialogExists 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 OtherScreenTest : BaseComposeTest() { - @Test - fun `on back click should send BackClick`() { - val viewModel: OtherViewModel = mockk { - every { eventFlow } returns emptyFlow() - every { trySendAction(OtherAction.BackClick) } returns Unit - } - composeTestRule.setContent { - OtherScreen( - viewModel = viewModel, - onNavigateBack = { }, - ) - } - composeTestRule.onNodeWithContentDescription("Back").performClick() - verify { viewModel.trySendAction(OtherAction.BackClick) } + 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 } - @Test - fun `on NavigateOther should call onNavigateToOther`() { - var haveCalledNavigateBack = false - val viewModel = mockk { - every { eventFlow } returns flowOf(OtherEvent.NavigateBack) - } + @Before + fun setup() { composeTestRule.setContent { OtherScreen( viewModel = viewModel, onNavigateBack = { haveCalledNavigateBack = true }, ) } + } + + @Test + fun `on allow screen capture confirm should send AllowScreenCaptureToggle`() { + composeTestRule.onNodeWithText("Allow screen capture").performClick() + composeTestRule.onNodeWithText("Yes").performClick() + composeTestRule.assertNoDialogExists() + + verify { viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(true)) } + } + + @Test + fun `on allow screen capture cancel should dismiss dialog`() { + composeTestRule.onNodeWithText("Allow screen capture").performClick() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + composeTestRule.assertNoDialogExists() + } + + @Test + fun `on allow screen capture row click should display confirm enable screen capture dialog`() { + composeTestRule.onNodeWithText("Allow screen capture").performClick() + composeTestRule + .onAllNodesWithText("Allow screen capture") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on allow sync toggle should send AllowSyncToggle`() { + composeTestRule.onNodeWithText("Allow sync on refresh").performClick() + verify { viewModel.trySendAction(OtherAction.AllowSyncToggle(true)) } + } + + @Test + fun `on back click should send BackClick`() { + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify { viewModel.trySendAction(OtherAction.BackClick) } + } + + @Test + fun `on clear clipboard row click should show show clipboard selection dialog`() { + composeTestRule.onNodeWithText("Clear clipboard").performClick() + composeTestRule + .onAllNodesWithText("Clear clipboard") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on clear clipboard dialog item click should send ClearClipboardFrequencyChange`() { + composeTestRule.onNodeWithText("Clear clipboard").performClick() + composeTestRule + .onAllNodesWithText("10 seconds") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + composeTestRule.assertNoDialogExists() + + verify { + viewModel.trySendAction( + OtherAction.ClearClipboardFrequencyChange( + clearClipboardFrequency = OtherState.ClearClipboardFrequency.TEN_SECONDS, + ), + ) + } + } + + @Test + fun `on clear clipboard dialog cancel should dismiss dialog`() { + composeTestRule.onNodeWithText("Clear clipboard").performClick() + composeTestRule.onNodeWithText("Cancel").performClick() + composeTestRule.assertNoDialogExists() + } + + @Test + fun `on sync now button click should send SyncNowButtonClick`() { + composeTestRule.onNodeWithText("Sync now").performClick() + verify { viewModel.trySendAction(OtherAction.SyncNowButtonClick) } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(OtherEvent.NavigateBack) assertTrue(haveCalledNavigateBack) } } + +private val DEFAULT_STATE = OtherState( + allowScreenCapture = false, + allowSyncOnRefresh = false, + clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT, + lastSyncTime = "5/14/2023 4:52 PM", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt index 9260293528..01b60a4e82 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt @@ -1,19 +1,131 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class OtherViewModelTest : BaseViewModelTest() { + val vaultRepository = mockk() + + @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( + clearClipboardFrequency = OtherState.ClearClipboardFrequency.FIVE_MINUTES, + ) + val viewModel = createViewModel(state = state) + assertEquals(state, viewModel.stateFlow.value) + } + + @Test + fun `on AllowScreenCaptureToggled should update value in state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + expectNoEvents() + } + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(true)) + assertEquals( + DEFAULT_STATE.copy(allowScreenCapture = true), + awaitItem(), + ) + } + } + + @Test + fun `on AllowSyncToggled should update value in state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + expectNoEvents() + } + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + viewModel.trySendAction(OtherAction.AllowSyncToggle(true)) + assertEquals( + DEFAULT_STATE.copy(allowSyncOnRefresh = true), + awaitItem(), + ) + } + } @Test fun `on BackClick should emit NavigateBack`() = runTest { - val viewModel = OtherViewModel() + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(OtherAction.BackClick) assertEquals(OtherEvent.NavigateBack, awaitItem()) } } + + @Test + fun `on ClearClipboardFrequencyChange should update state`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + viewModel.trySendAction( + OtherAction.ClearClipboardFrequencyChange( + clearClipboardFrequency = OtherState.ClearClipboardFrequency.ONE_MINUTE, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + clearClipboardFrequency = OtherState.ClearClipboardFrequency.ONE_MINUTE, + ), + awaitItem(), + ) + } + } + + @Test + fun `on SyncNowButtonClick should sync repo`() = runTest { + every { vaultRepository.sync() } just runs + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(OtherAction.SyncNowButtonClick) + expectNoEvents() + } + verify { vaultRepository.sync() } + } + + private fun createViewModel( + state: OtherState? = null, + ) = OtherViewModel( + vaultRepo = vaultRepository, + savedStateHandle = SavedStateHandle().apply { + set("state", state) + }, + ) + + companion object { + private val DEFAULT_STATE = OtherState( + allowScreenCapture = false, + allowSyncOnRefresh = false, + clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT, + lastSyncTime = "5/14/2023 4:52 PM", + ) + } }