BIT-1147: Adding UI for empty and content view states in BlockAutoFillScreen (#688)

This commit is contained in:
Joshua Queen 2024-01-20 11:18:56 -05:00 committed by Álison Fernandes
parent 9779cb9cf2
commit 6dd4a31a57
10 changed files with 394 additions and 15 deletions

View file

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

View file

@ -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<BlockAutoFillState, BlockAutoFillEvent, BlockAutoFillAction>(
initialState = savedStateHandle[KEY_STATE]
?: BlockAutoFillState(
viewState = BlockAutoFillState.ViewState.Empty,
),
?: BlockAutoFillState(viewState = BlockAutoFillState.ViewState.Empty),
) {
init {
updateContentWithUris(
uris = settingsRepository.blockedAutofillUris,
)
}
private fun updateContentWithUris(uris: List<String>) {
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<String> = emptyList(),
) : ViewState()
/**
* Represents an empty content state for the [BlockAutoFillScreen].
*/

View file

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

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="159dp"
android:height="159dp"
android:viewportWidth="159"
android:viewportHeight="159">
<path
android:pathData="M79.48,158.95C123.37,158.95 158.95,123.37 158.95,79.48C158.95,35.58 123.37,0 79.48,0C35.58,0 0,35.58 0,79.48C0,123.37 35.58,158.95 79.48,158.95Z"
android:fillColor="#DDE3EA"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="159dp"
android:height="159dp"
android:viewportWidth="159"
android:viewportHeight="159">
<path
android:pathData="M79.47,155.05C121.21,155.05 155.05,121.21 155.05,79.47C155.05,37.74 121.21,3.9 79.47,3.9C37.74,3.9 3.9,37.74 3.9,79.47C3.9,121.21 37.74,155.05 79.47,155.05ZM79.47,158.95C123.37,158.95 158.95,123.37 158.95,79.47C158.95,35.58 123.37,0 79.47,0C35.58,0 0,35.58 0,79.47C0,123.37 35.58,158.95 79.47,158.95Z"
android:fillColor="#757780"
android:fillType="evenOdd"/>
<path
android:pathData="M101.75,64.02C103.68,39.69 93.87,18.9 76.45,4.23C76.04,3.88 75.99,3.26 76.33,2.85C76.68,2.44 77.3,2.39 77.71,2.73C95.57,17.79 105.68,39.18 103.7,64.17C100.42,105.42 64.15,136.18 22.69,132.89C22.15,132.85 21.75,132.38 21.79,131.84C21.84,131.31 22.31,130.91 22.84,130.95C63.24,134.15 98.57,104.18 101.75,64.02Z"
android:fillColor="#757780"
android:fillType="evenOdd"/>
<path
android:pathData="M86.99,128.14C89,128.29 111.17,129.79 129.89,118.99C136.53,115.16 140.45,112.5 143.48,109.9C146.51,107.3 148.67,104.75 151.79,101.06L151.82,101.02C152.16,100.61 152.78,100.56 153.19,100.91C153.6,101.26 153.65,101.88 153.31,102.29L153.25,102.35C150.16,106.01 147.91,108.68 144.75,111.38C141.59,114.09 137.55,116.82 130.87,120.68C111.6,131.8 88.91,130.24 86.86,130.09C42.89,126.84 9.06,87.36 12.16,43.9C12.2,43.37 12.67,42.96 13.21,43C13.74,43.04 14.15,43.51 14.11,44.05C11.08,86.43 44.1,124.97 86.99,128.14Z"
android:fillColor="#757780"
android:fillType="evenOdd"/>
<path
android:pathData="M48.59,55.04C27.23,71.21 14.31,94.73 13.28,117.81C13.25,118.36 12.8,118.78 12.26,118.75C11.73,118.73 11.32,118.28 11.34,117.73C12.41,93.98 25.68,69.93 47.44,53.46C83.37,26.27 133.01,29.77 156.71,62.17C157.03,62.61 156.94,63.22 156.51,63.55C156.08,63.87 155.48,63.79 155.16,63.35C132.23,32.01 83.86,28.34 48.59,55.04Z"
android:fillColor="#757780"
android:fillType="evenOdd"/>
<path
android:pathData="M105.32,37.06C105.32,40.29 102.7,42.91 99.47,42.91C96.23,42.91 93.61,40.29 93.61,37.06C93.61,33.82 96.23,31.2 99.47,31.2C102.7,31.2 105.32,33.82 105.32,37.06Z"
android:fillColor="#757780"/>
<path
android:pathData="M27.79,84.84C27.79,88.07 25.06,90.69 21.7,90.69C18.33,90.69 15.6,88.07 15.6,84.84C15.6,81.61 18.33,78.99 21.7,78.99C25.06,78.99 27.79,81.61 27.79,84.84Z"
android:fillColor="#757780"/>
<path
android:pathData="M70.21,123.36C70.21,126.86 67.37,129.7 63.87,129.7C60.37,129.7 57.53,126.86 57.53,123.36C57.53,119.86 60.37,117.02 63.87,117.02C67.37,117.02 70.21,119.86 70.21,123.36Z"
android:fillColor="#757780"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M19.962,1.704C20.048,2.148 19.961,2.642 19.592,3.042C18.794,3.906 13.324,9.578 12.46,10.474C12.299,10.641 12.102,10.752 11.889,10.809L9.248,11.519C9.019,11.58 8.775,11.507 8.618,11.329C8.461,11.152 8.418,10.901 8.506,10.681L9.614,7.938C9.674,7.787 9.763,7.649 9.883,7.528C10.628,6.777 15.982,1.387 16.879,0.481C17.268,0.087 17.758,-0.042 18.222,0.025C18.664,0.088 19.057,0.321 19.349,0.607C19.642,0.893 19.879,1.277 19.962,1.704ZM18.045,1.262C17.945,1.248 17.86,1.267 17.767,1.36C16.87,2.266 11.528,7.645 10.773,8.406L10.135,9.986L11.564,9.602C12.45,8.684 17.889,3.044 18.673,2.194C18.734,2.128 18.757,2.054 18.735,1.941C18.709,1.809 18.622,1.645 18.475,1.501C18.328,1.357 18.168,1.28 18.045,1.262Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
<path
android:pathData="M10,1.25H3.125C2.089,1.25 1.25,2.089 1.25,3.125V16.875C1.25,17.91 2.089,18.75 3.125,18.75H16.875C17.91,18.75 18.75,17.91 18.75,16.875V10C18.75,9.655 18.47,9.375 18.125,9.375C17.78,9.375 17.5,9.655 17.5,10V16.875C17.5,17.22 17.22,17.5 16.875,17.5H3.125C2.78,17.5 2.5,17.22 2.5,16.875V3.125C2.5,2.78 2.78,2.5 3.125,2.5H10C10.345,2.5 10.625,2.22 10.625,1.875C10.625,1.53 10.345,1.25 10,1.25Z"
android:fillColor="#1B1B1F"/>
</vector>

View file

@ -33,6 +33,7 @@
<color name="on_error_container">@color/red_410002</color>
<color name="surface_dim">@color/grey_DBD9DD</color>
<color name="surface">@color/grey_FBF8FD</color>
<color name="surface_variant">@color/grey_DDE3EA</color>
<color name="surface_bright">@color/grey_FBF8FD</color>
<color name="surface_container_lowest">@color/white_FFFFFF</color>
<color name="surface_container_low">@color/white_F5F3F7</color>
@ -82,6 +83,7 @@
<color name="dark_on_error_container">@color/red_FFDAD6</color>
<color name="dark_surface_dim">@color/grey_131316</color>
<color name="dark_surface">@color/grey_131316</color>
<color name="dark_surface_variant">@color/grey_45464F</color>
<color name="dark_surface_bright">@color/grey_39393C</color>
<color name="dark_surface_container_lowest">@color/grey_0D0E11</color>
<color name="dark_surface_container_low">@color/grey_1B1B1F</color>

View file

@ -16,6 +16,7 @@
<!-- Greys -->
<color name="grey_FBF8FD">#FBF8FD</color>
<color name="grey_DDE3EA">#DDE3EA</color>
<color name="grey_DDE2F9">#DDE2F9</color>
<color name="grey_EFEFF4">#EFEFF4</color>
<color name="grey_F2F0F4">#F2F0F4</color>

View file

@ -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<BlockAutoFillEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<BlockAutoFillViewModel>(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,
)

View file

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