From 2f0ae889e41c08246dbab292b1375a2b23e765ac Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 15 Jan 2024 13:21:28 -0600 Subject: [PATCH] BIT-461: Add pull-to-refresh to vault screen (#619) --- .../ui/vault/feature/vault/VaultScreen.kt | 18 ++++- .../ui/vault/feature/vault/VaultViewModel.kt | 73 ++++++++++++++++++- .../ui/vault/feature/vault/VaultScreenTest.kt | 1 + .../vault/feature/vault/VaultViewModelTest.kt | 42 ++++++++++- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index cd578908b2..13cb56eb6c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -15,8 +15,11 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,6 +61,7 @@ import kotlinx.collections.immutable.toImmutableList /** * The vault screen for the application. */ +@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable fun VaultScreen( @@ -69,9 +73,18 @@ fun VaultScreen( onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, intentHandler: IntentHandler = IntentHandler(LocalContext.current), ) { + val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current + val pullToRefreshState = rememberPullToRefreshState().takeIf { state.isPullToRefreshEnabled } + LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) { + if (pullToRefreshState?.isRefreshing == true) { + viewModel.trySendAction(VaultAction.RefreshPull) + } + } EventsEffect(viewModel = viewModel) { event -> when (event) { + VaultEvent.DismissPullToRefresh -> pullToRefreshState?.endRefresh() + VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen() VaultEvent.NavigateToVaultSearchScreen -> { @@ -103,7 +116,8 @@ fun VaultScreen( } } VaultScreenScaffold( - state = viewModel.stateFlow.collectAsState().value, + state = state, + pullToRefreshState = pullToRefreshState, vaultFilterTypeSelect = remember(viewModel) { { viewModel.trySendAction(VaultAction.VaultFilterTypeSelect(it)) } }, @@ -181,6 +195,7 @@ fun VaultScreen( @Composable private fun VaultScreenScaffold( state: VaultState, + pullToRefreshState: PullToRefreshState?, vaultFilterTypeSelect: (VaultFilterType) -> Unit, addItemClickAction: () -> Unit, searchIconClickAction: () -> Unit, @@ -311,6 +326,7 @@ private fun VaultScreenScaffold( } } }, + pullToRefreshState = pullToRefreshState, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Box { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index d3b03527a7..70c748f5fa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -29,6 +30,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize @@ -41,6 +43,7 @@ import javax.inject.Inject @HiltViewModel class VaultViewModel @Inject constructor( private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, ) : BaseViewModel( initialState = run { @@ -57,6 +60,7 @@ class VaultViewModel @Inject constructor( vaultFilterData = vaultFilterData, viewState = VaultState.ViewState.Loading, isPremium = userState.activeAccount.isPremium, + isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, ) }, ) { @@ -67,6 +71,12 @@ class VaultViewModel @Inject constructor( get() = state.vaultFilterData?.selectedVaultFilterType ?: VaultFilterType.AllVaults init { + settingsRepository + .getPullToRefreshEnabledFlow() + .map { VaultAction.Internal.PullToRefreshEnableReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + vaultRepository .vaultDataStateFlow .onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) } @@ -103,8 +113,8 @@ class VaultViewModel @Inject constructor( is VaultAction.VaultItemClick -> handleVaultItemClick(action) is VaultAction.TryAgainClick -> handleTryAgainClick() is VaultAction.DialogDismiss -> handleDialogDismiss() - is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) - is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + is VaultAction.RefreshPull -> handleRefreshPull() + is VaultAction.Internal -> handleInternalAction(action) } } @@ -228,6 +238,31 @@ class VaultViewModel @Inject constructor( } } + private fun handleRefreshPull() { + // The Pull-To-Refresh composable is already in the refreshing state. + // We will reset that state when sendDataStateFlow emits later on. + vaultRepository.sync() + } + + private fun handleInternalAction(action: VaultAction.Internal) { + when (action) { + is VaultAction.Internal.PullToRefreshEnableReceive -> { + handlePullToRefreshEnableReceive(action) + } + + is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) + is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + } + } + + private fun handlePullToRefreshEnableReceive( + action: VaultAction.Internal.PullToRefreshEnableReceive, + ) { + mutableStateFlow.update { + it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled) + } + } + private fun handleUserStateUpdateReceive(action: VaultAction.Internal.UserStateUpdateReceive) { // Leave the current data alone if there is no UserState; we are in the process of logging // out. @@ -278,6 +313,7 @@ class VaultViewModel @Inject constructor( errorTitle = R.string.an_error_has_occurred.asText(), errorMessage = R.string.generic_error_message.asText(), ) + sendEvent(VaultEvent.DismissPullToRefresh) } private fun vaultLoadedReceive(vaultData: DataState.Loaded) { @@ -297,6 +333,7 @@ class VaultViewModel @Inject constructor( dialog = null, ) } + sendEvent(VaultEvent.DismissPullToRefresh) } private fun vaultLoadingReceive() { @@ -311,6 +348,7 @@ class VaultViewModel @Inject constructor( errorTitle = R.string.internet_connection_required_title.asText(), errorMessage = R.string.internet_connection_required_message.asText(), ) + sendEvent(VaultEvent.DismissPullToRefresh) } private fun vaultPendingReceive(vaultData: DataState.Pending) { @@ -351,6 +389,7 @@ data class VaultState( // Internal-use properties val isSwitchingAccounts: Boolean = false, val isPremium: Boolean, + private val isPullToRefreshSettingEnabled: Boolean, ) : Parcelable { /** @@ -358,6 +397,12 @@ data class VaultState( */ val avatarColor: Color get() = avatarColorString.hexToColor() + /** + * Indicates that the pull-to-refresh should be enabled in the UI. + */ + val isPullToRefreshEnabled: Boolean + get() = isPullToRefreshSettingEnabled && viewState.isPullToRefreshEnabled + /** * Represents the specific view states for the [VaultScreen]. */ @@ -375,6 +420,11 @@ data class VaultState( */ abstract val hasVaultFilter: Boolean + /** + * Indicates the pull-to-refresh feature should be available during the current state. + */ + abstract val isPullToRefreshEnabled: Boolean + /** * Loading state for the [VaultScreen], signifying that the content is being processed. */ @@ -382,6 +432,7 @@ data class VaultState( data object Loading : ViewState() { override val hasFab: Boolean get() = false override val hasVaultFilter: Boolean get() = false + override val isPullToRefreshEnabled: Boolean get() = false } /** @@ -391,6 +442,7 @@ data class VaultState( data object NoItems : ViewState() { override val hasFab: Boolean get() = true override val hasVaultFilter: Boolean get() = true + override val isPullToRefreshEnabled: Boolean get() = true } /** @@ -403,6 +455,7 @@ data class VaultState( ) : ViewState() { override val hasFab: Boolean get() = false override val hasVaultFilter: Boolean get() = false + override val isPullToRefreshEnabled: Boolean get() = true } /** @@ -434,6 +487,7 @@ data class VaultState( ) : ViewState() { override val hasFab: Boolean get() = true override val hasVaultFilter: Boolean get() = true + override val isPullToRefreshEnabled: Boolean get() = true } /** @@ -597,6 +651,11 @@ data class VaultState( * Models effects for the [VaultScreen]. */ sealed class VaultEvent { + /** + * Dismisses the pull-to-refresh indicator. + */ + data object DismissPullToRefresh : VaultEvent() + /** * Navigate to the Vault Search screen. */ @@ -648,6 +707,11 @@ sealed class VaultEvent { * Models actions for the [VaultScreen]. */ sealed class VaultAction { + /** + * User has triggered a pull to refresh. + */ + data object RefreshPull : VaultAction() + /** * Click the add an item button. * This can either be the floating action button or actual add an item button. @@ -776,6 +840,11 @@ sealed class VaultAction { */ sealed class Internal : VaultAction() { + /** + * Indicates that the pull to refresh feature toggle has changed. + */ + data class PullToRefreshEnableReceive(val isPullToRefreshEnabled: Boolean) : Internal() + /** * Indicates a change in user state has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 78bb9a452c..ef81d713a1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1070,6 +1070,7 @@ private val DEFAULT_STATE: VaultState = VaultState( ), viewState = VaultState.ViewState.Loading, isPremium = false, + isPullToRefreshSettingEnabled = false, ) private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 594729c792..22cb42c462 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView @@ -35,6 +36,8 @@ import org.junit.jupiter.api.Test @Suppress("LargeClass") class VaultViewModelTest : BaseViewModelTest() { + private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false) + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) @@ -52,10 +55,14 @@ class VaultViewModelTest : BaseViewModelTest() { every { switchAccount(any()) } answers { switchAccountResult } } + private val settingsRepository: SettingsRepository = mockk { + every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow + } + private val vaultRepository: VaultRepository = mockk { every { vaultDataStateFlow } returns mutableVaultDataStateFlow - every { sync() } returns Unit + every { sync() } just runs every { lockVaultForCurrentUser() } just runs every { lockVault(any()) } just runs } @@ -425,7 +432,7 @@ class VaultViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Loaded with items when manually syncing with the sync button should update state to Content and show a success Toast`() = + fun `vaultDataStateFlow Loaded with items when manually syncing with the sync button should update state to Content, show a success Toast, and dismiss pull to refresh`() = runTest { val expectedState = createMockVaultState( viewState = VaultState.ViewState.Content( @@ -461,6 +468,7 @@ class VaultViewModelTest : BaseViewModelTest() { VaultEvent.ShowToast(R.string.syncing_complete.asText()), awaitItem(), ) + assertEquals(VaultEvent.DismissPullToRefresh, awaitItem()) } } @@ -485,7 +493,7 @@ class VaultViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Loaded with empty items when manually syncing with the sync button should update state to NoItems and show a success Toast`() = + fun `vaultDataStateFlow Loaded with empty items when manually syncing with the sync button should update state to NoItems, show a success Toast, and dismiss pull to refresh`() = runTest { val expectedState = createMockVaultState( viewState = VaultState.ViewState.NoItems, @@ -508,6 +516,7 @@ class VaultViewModelTest : BaseViewModelTest() { VaultEvent.ShowToast(R.string.syncing_complete.asText()), awaitItem(), ) + assertEquals(VaultEvent.DismissPullToRefresh, awaitItem()) } } @@ -998,9 +1007,35 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Test + fun `RefreshPull should call vault repository sync`() { + val viewModel = createViewModel() + + viewModel.trySendAction(VaultAction.RefreshPull) + + verify(exactly = 1) { + vaultRepository.sync() + } + } + + @Test + fun `PullToRefreshEnableReceive should update isPullToRefreshEnabled`() = runTest { + val viewModel = createViewModel() + + viewModel.trySendAction( + VaultAction.Internal.PullToRefreshEnableReceive(isPullToRefreshEnabled = true), + ) + + assertEquals( + DEFAULT_STATE.copy(isPullToRefreshSettingEnabled = true), + viewModel.stateFlow.value, + ) + } + private fun createViewModel(): VaultViewModel = VaultViewModel( authRepository = authRepository, + settingsRepository = settingsRepository, vaultRepository = vaultRepository, ) } @@ -1084,4 +1119,5 @@ private fun createMockVaultState( dialog = dialog, isSwitchingAccounts = false, isPremium = true, + isPullToRefreshSettingEnabled = false, )