BIT-930: Add UI for Other screen (#477)

This commit is contained in:
Caleb Derosier 2024-01-04 19:24:12 -07:00 committed by Álison Fernandes
parent 02c8f4bfec
commit b24c2ba7e7
4 changed files with 492 additions and 27 deletions

View file

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

View file

@ -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<Unit, OtherEvent, OtherAction>(
initialState = Unit,
class OtherViewModel @Inject constructor(
private val vaultRepo: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<OtherState, OtherEvent, OtherAction>(
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()
}

View file

@ -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<OtherEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<OtherViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Test
fun `on NavigateOther should call onNavigateToOther`() {
var haveCalledNavigateBack = false
val viewModel = mockk<OtherViewModel> {
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",
)

View file

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