From 6dd4a31a57516851465204d30f06aeb1e6d1deab Mon Sep 17 00:00:00 2001 From: Joshua Queen <139182194+joshua-livefront@users.noreply.github.com> Date: Sat, 20 Jan 2024 11:18:56 -0500 Subject: [PATCH] BIT-1147: Adding UI for empty and content view states in BlockAutoFillScreen (#688) --- .../blockautofill/BlockAutoFillScreen.kt | 197 +++++++++++++++++- .../blockautofill/BlockAutoFillViewModel.kt | 39 +++- .../ui/platform/theme/BitwardenTheme.kt | 2 + .../drawable/ic_blocked_uri_background.xml | 10 + .../drawable/ic_blocked_uri_foreground.xml | 31 +++ app/src/main/res/drawable/ic_edit_alt.xml | 13 ++ app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/colors_palette.xml | 1 + .../blockautofill/BlockAutoFillScreenTest.kt | 77 +++++++ .../BlockAutoFillViewModelTest.kt | 37 +++- 10 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 app/src/main/res/drawable/ic_blocked_uri_background.xml create mode 100644 app/src/main/res/drawable/ic_blocked_uri_foreground.xml create mode 100644 app/src/main/res/drawable/ic_edit_alt.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt index abbf83b22..3f5c27690 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreen.kt @@ -1,12 +1,30 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.Spacer +import androidx.compose.foundation.layout.defaultMinSize 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.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -18,16 +36,21 @@ 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.text.style.TextAlign +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.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar /** * Displays the block auto-fill screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockAutoFillScreen( @@ -49,7 +72,7 @@ fun BlockAutoFillScreen( .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = stringResource(id = R.string.autofill), + title = stringResource(id = R.string.block_auto_fill), scrollBehavior = scrollBehavior, navigationIcon = painterResource(id = R.drawable.ic_back), navigationIconContentDescription = stringResource(id = R.string.back), @@ -58,16 +81,172 @@ fun BlockAutoFillScreen( }, ) }, + floatingActionButton = { + AnimatedVisibility( + visible = true, + enter = scaleIn(), + exit = scaleOut(), + ) { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.primaryContainer, + onClick = {}, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_item), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + }, ) { innerPadding -> - Column( + LazyColumn( modifier = Modifier .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .fillMaxSize(), ) { - Text(text = "Not yet implemented") + when (val viewState = state.viewState) { + is BlockAutoFillState.ViewState.Content -> { + item { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 20.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource( + id = R.string.auto_fill_will_not_be_offered_for_these_ur_is, + ), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } + + items(viewState.blockedUris) { uri -> + BlockAutoFillListItem( + label = uri, + onClick = {}, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } + + is BlockAutoFillState.ViewState.Empty -> { + item { + BlockAutoFillNoItems( + addItemClickAction = {}, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } + } } } } + +/** + * No items view for the [BlockAutoFillScreen]. + */ +@Composable +private fun BlockAutoFillNoItems( + addItemClickAction: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource( + id = R.drawable.ic_blocked_uri_background, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceVariant, + ) + + Icon( + painter = painterResource( + id = R.drawable.ic_blocked_uri_foreground, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResource(id = R.string.auto_fill_will_not_be_offered_for_these_ur_is), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + onClick = addItemClickAction, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text( + text = stringResource(id = R.string.new_blocked_uri), + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun BlockAutoFillListItem( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .bottomDivider(paddingStart = 16.dp) + .defaultMinSize(minHeight = 56.dp) + .padding(end = 8.dp, top = 16.dp, bottom = 16.dp) + .then(modifier), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + painter = painterResource(id = R.drawable.ic_edit_alt), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt index 4e81f5870..3c2d602bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModel.kt @@ -2,9 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.android.parcel.Parcelize +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject private const val KEY_STATE = "state" @@ -13,15 +15,32 @@ private const val KEY_STATE = "state" * View model for the blocked autofill URIs screen. */ @HiltViewModel -@Suppress("TooManyFunctions", "LargeClass") class BlockAutoFillViewModel @Inject constructor( + private val settingsRepository: SettingsRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: BlockAutoFillState( - viewState = BlockAutoFillState.ViewState.Empty, - ), + ?: BlockAutoFillState(viewState = BlockAutoFillState.ViewState.Empty), ) { + init { + updateContentWithUris( + uris = settingsRepository.blockedAutofillUris, + ) + } + + private fun updateContentWithUris(uris: List) { + mutableStateFlow.update { currentState -> + if (uris.isNotEmpty()) { + currentState.copy( + viewState = BlockAutoFillState.ViewState.Content(uris.map { it }), + ) + } else { + currentState.copy( + viewState = BlockAutoFillState.ViewState.Empty, + ) + } + } + } override fun handleAction(action: BlockAutoFillAction) { when (action) { @@ -51,6 +70,16 @@ data class BlockAutoFillState( */ sealed class ViewState : Parcelable { + /** + * Represents a content state for the [BlockAutoFillScreen]. + * + * @property blockedUris The list of blocked URIs. + */ + @Parcelize + data class Content( + val blockedUris: List = emptyList(), + ) : ViewState() + /** * Represents an empty content state for the [BlockAutoFillScreen]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index f74dac47b..137c9d481 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -113,6 +113,7 @@ private fun darkColorScheme(context: Context): ColorScheme = surfaceContainerHighest = R.color.dark_surface_container_highest.toColor(context), surfaceContainerLow = R.color.dark_surface_container_low.toColor(context), surfaceContainerLowest = R.color.dark_surface_container_lowest.toColor(context), + surfaceVariant = R.color.dark_surface_variant.toColor(context), surfaceDim = R.color.dark_surface_dim.toColor(context), onSurface = R.color.dark_on_surface.toColor(context), onSurfaceVariant = R.color.dark_on_surface_variant.toColor(context), @@ -149,6 +150,7 @@ private fun lightColorScheme(context: Context): ColorScheme = surfaceContainerHighest = R.color.surface_container_highest.toColor(context), surfaceContainerLow = R.color.surface_container_low.toColor(context), surfaceContainerLowest = R.color.surface_container_lowest.toColor(context), + surfaceVariant = R.color.surface_variant.toColor(context), surfaceDim = R.color.surface_dim.toColor(context), onSurface = R.color.on_surface.toColor(context), onSurfaceVariant = R.color.on_surface_variant.toColor(context), diff --git a/app/src/main/res/drawable/ic_blocked_uri_background.xml b/app/src/main/res/drawable/ic_blocked_uri_background.xml new file mode 100644 index 000000000..e125d705c --- /dev/null +++ b/app/src/main/res/drawable/ic_blocked_uri_background.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_blocked_uri_foreground.xml b/app/src/main/res/drawable/ic_blocked_uri_foreground.xml new file mode 100644 index 000000000..b650a63be --- /dev/null +++ b/app/src/main/res/drawable/ic_blocked_uri_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_edit_alt.xml b/app/src/main/res/drawable/ic_edit_alt.xml new file mode 100644 index 000000000..932aa8c16 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_alt.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 999b53c7f..6259bea04 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -33,6 +33,7 @@ @color/red_410002 @color/grey_DBD9DD @color/grey_FBF8FD + @color/grey_DDE3EA @color/grey_FBF8FD @color/white_FFFFFF @color/white_F5F3F7 @@ -82,6 +83,7 @@ @color/red_FFDAD6 @color/grey_131316 @color/grey_131316 + @color/grey_45464F @color/grey_39393C @color/grey_0D0E11 @color/grey_1B1B1F diff --git a/app/src/main/res/values/colors_palette.xml b/app/src/main/res/values/colors_palette.xml index f401560a7..65e99f9e6 100644 --- a/app/src/main/res/values/colors_palette.xml +++ b/app/src/main/res/values/colors_palette.xml @@ -16,6 +16,7 @@ #FBF8FD + #DDE3EA #DDE2F9 #EFEFF4 #F2F0F4 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt new file mode 100644 index 000000000..ec48d0b37 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillScreenTest.kt @@ -0,0 +1,77 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill + +import androidx.compose.ui.test.assertIsDisplayed +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.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BlockAutoFillScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = 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 { + BlockAutoFillScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on back click should send BackClick`() { + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify { viewModel.trySendAction(BlockAutoFillAction.BackClick) } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(BlockAutoFillEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `Screen should display empty state view when in ViewState Empty`() { + composeTestRule + .onNodeWithText("Auto-fill will not be offered for these URIs.") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("New blocked URI") + .assertIsDisplayed() + } + + @Test + fun `Screen should display content state view when in ViewState Content`() { + mutableStateFlow.value = BlockAutoFillState( + viewState = BlockAutoFillState.ViewState.Content(listOf("uri1", "uri2")), + ) + + listOf("uri1", "uri2").forEach { uri -> + composeTestRule + .onNodeWithText(uri) + .assertIsDisplayed() + } + } +} + +private val DEFAULT_STATE: BlockAutoFillState = BlockAutoFillState( + BlockAutoFillState.ViewState.Empty, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt index 4a3200f50..6bb306593 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/blockautofill/BlockAutoFillViewModelTest.kt @@ -2,13 +2,47 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class BlockAutoFillViewModelTest : BaseViewModelTest() { + private val settingsRepository: SettingsRepository = mockk { + every { blockedAutofillUris } returns listOf("blockedUri") + } + + @Suppress("MaxLineLength") + @Test + fun `initial state with blocked URIs updates state to ViewState Content`() = + runTest { + val viewModel = createViewModel() + val expectedState = BlockAutoFillState( + viewState = BlockAutoFillState.ViewState.Content( + blockedUris = listOf("blockedUri"), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `initial state with empty blocked URIs maintains state as ViewState Empty`() = + runTest { + every { settingsRepository.blockedAutofillUris } returns emptyList() + val viewModel = createViewModel() + val expectedState = BlockAutoFillState( + viewState = BlockAutoFillState.ViewState.Empty, + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + @Test fun `on BackClick should emit NavigateBack`() = runTest { val viewModel = createViewModel() @@ -22,6 +56,7 @@ class BlockAutoFillViewModelTest : BaseViewModelTest() { state: BlockAutoFillState? = DEFAULT_STATE, ): BlockAutoFillViewModel = BlockAutoFillViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, + settingsRepository = settingsRepository, ) }