From 942f6e2475aa11852536203d53bba8dd5fdca539 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 7 Apr 2025 13:33:15 -0500 Subject: [PATCH] PM-19938: Add empty and loading state to the recorded logs screen (#5001) --- .../content/BitwardenEmptyContent.kt | 56 ++++++++++++++ .../platform/components/icon/BitwardenIcon.kt | 4 +- .../recordedLogs/RecordedLogsScreen.kt | 25 ++++++- .../recordedLogs/RecordedLogsViewModel.kt | 29 +++++++- .../res/drawable-night/il_secure_devices.xml | 73 +++++++++++++++++++ .../main/res/drawable/il_secure_devices.xml | 73 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../recordedlogs/RecordedLogsScreenTest.kt | 23 +++++- .../recordedlogs/RecordedLogsViewModelTest.kt | 5 +- 9 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenEmptyContent.kt create mode 100644 app/src/main/res/drawable-night/il_secure_devices.xml create mode 100644 app/src/main/res/drawable/il_secure_devices.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenEmptyContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenEmptyContent.kt new file mode 100644 index 0000000000..9d25a2153f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenEmptyContent.kt @@ -0,0 +1,56 @@ +package com.x8bit.bitwarden.ui.platform.components.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.ui.platform.base.util.nullableTestTag +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.icon.BitwardenIcon +import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A Bitwarden-themed, re-usable empty state. + */ +@Composable +fun BitwardenEmptyContent( + text: String, + modifier: Modifier = Modifier, + illustrationData: IconData? = null, + labelTestTag: String? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + illustrationData?.let { + BitwardenIcon( + iconData = it, + modifier = Modifier.size(size = 124.dp), + ) + Spacer(modifier = Modifier.height(height = 24.dp)) + } + Text( + text = text, + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .nullableTestTag(tag = labelTestTag), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/icon/BitwardenIcon.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/icon/BitwardenIcon.kt index 6125c9d618..f018e0ac38 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/icon/BitwardenIcon.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/icon/BitwardenIcon.kt @@ -15,15 +15,15 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter * Represents a Bitwarden icon that is either locally loaded or loaded using glide. * * @param iconData Label for the text field. - * @param tint the color to be applied as the tint for the icon. * @param modifier A [Modifier] for the composable. + * @param tint the color to be applied as the tint for the icon. */ @OptIn(ExperimentalGlideComposeApi::class) @Composable fun BitwardenIcon( iconData: IconData, - tint: Color, modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, ) { when (iconData) { is IconData.Network -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt index 4aa6615f39..b5b69afcb5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt @@ -1,17 +1,23 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource 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.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenEmptyContent +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter @@ -24,6 +30,7 @@ fun RecordedLogsScreen( onNavigateBack: () -> Unit, viewModel: RecordedLogsViewModel = hiltViewModel(), ) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() EventsEffect(viewModel) { event -> when (event) { RecordedLogsEvent.NavigateBack -> onNavigateBack() @@ -44,6 +51,22 @@ fun RecordedLogsScreen( }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { - // TODO: PM-19593 Create the flight recorder UI. + when (state.viewState) { + RecordedLogsState.ViewState.Content -> { + // TODO: PM-19593 Create the flight recorder UI. + } + + RecordedLogsState.ViewState.Empty -> { + BitwardenEmptyContent( + text = stringResource(id = R.string.no_logs_recorded), + illustrationData = IconData.Local(iconRes = R.drawable.il_secure_devices), + modifier = Modifier.fillMaxSize(), + ) + } + + RecordedLogsState.ViewState.Loading -> { + BitwardenLoadingContent(modifier = Modifier.fillMaxSize()) + } + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt index 8f817a3955..aac5ed27c6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt @@ -15,7 +15,10 @@ class RecordedLogsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. - initialState = savedStateHandle[KEY_STATE] ?: RecordedLogsState, + initialState = savedStateHandle[KEY_STATE] + ?: RecordedLogsState( + viewState = RecordedLogsState.ViewState.Loading, + ), ) { override fun handleAction(action: RecordedLogsAction) { when (action) { @@ -31,7 +34,29 @@ class RecordedLogsViewModel @Inject constructor( /** * Models the UI state for the recorded logs screen. */ -data object RecordedLogsState +data class RecordedLogsState( + val viewState: ViewState, +) { + /** + * View states for the [RecordedLogsViewModel]. + */ + sealed class ViewState { + /** + * Represents the loading state for the [RecordedLogsViewModel]. + */ + data object Loading : ViewState() + + /** + * Represents the empty state for the [RecordedLogsViewModel]. + */ + data object Empty : ViewState() + + /** + * Represents the content state for the [RecordedLogsViewModel]. + */ + data object Content : ViewState() + } +} /** * Models events for the recorded logs screen. diff --git a/app/src/main/res/drawable-night/il_secure_devices.xml b/app/src/main/res/drawable-night/il_secure_devices.xml new file mode 100644 index 0000000000..1e7bf98429 --- /dev/null +++ b/app/src/main/res/drawable-night/il_secure_devices.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/il_secure_devices.xml b/app/src/main/res/drawable/il_secure_devices.xml new file mode 100644 index 0000000000..b5af5b1b9d --- /dev/null +++ b/app/src/main/res/drawable/il_secure_devices.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc453b0e43..7375b743e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1225,4 +1225,5 @@ Do you want to switch to this account? View recorded logs Enable flight recorder Recorded logs + No logs recorded diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt index b2d39ce384..b6f2c13042 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedlogs +import androidx.compose.ui.test.assertCountEquals +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.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest @@ -9,10 +12,12 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedL import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsScreen import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsState import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsViewModel +import com.x8bit.bitwarden.ui.util.isProgressBar import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -52,6 +57,22 @@ class RecordedLogsScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(RecordedLogsEvent.NavigateBack) assertTrue(onNavigateBackCalled) } + + @Test + fun `UI should change based on ViewState`() { + mutableStateFlow.update { it.copy(viewState = RecordedLogsState.ViewState.Loading) } + // There are 2 because of the pull-to-refresh + composeTestRule.onAllNodes(isProgressBar).assertCountEquals(2) + + mutableStateFlow.update { it.copy(viewState = RecordedLogsState.ViewState.Empty) } + composeTestRule.onNodeWithText(text = "No logs recorded").assertIsDisplayed() + + mutableStateFlow.update { it.copy(viewState = RecordedLogsState.ViewState.Content) } + // TODO: PM-19593 Assert content is displayed + } } -private val DEFAULT_STATE: RecordedLogsState = RecordedLogsState +private val DEFAULT_STATE: RecordedLogsState = + RecordedLogsState( + viewState = RecordedLogsState.ViewState.Loading, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt index 9f524d3169..b728dcbddf 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt @@ -38,4 +38,7 @@ class RecordedLogsViewModelTest : BaseViewModelTest() { ) } -private val DEFAULT_STATE: RecordedLogsState = RecordedLogsState +private val DEFAULT_STATE: RecordedLogsState = + RecordedLogsState( + viewState = RecordedLogsState.ViewState.Loading, + )