BIT-1499 listing screen pull-to-refresh (#690)

This commit is contained in:
David Perez 2024-01-19 16:16:15 -06:00 committed by Álison Fernandes
parent c487074de6
commit 88e4b45f7d
4 changed files with 222 additions and 90 deletions

View file

@ -8,8 +8,11 @@ import androidx.compose.material3.FloatingActionButton
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.remember
@ -40,6 +43,7 @@ import kotlinx.collections.immutable.persistentListOf
/**
* Displays the vault item listing screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VaultItemListingScreen(
onNavigateBack: () -> Unit,
@ -53,10 +57,19 @@ fun VaultItemListingScreen(
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
val pullToRefreshState = rememberPullToRefreshState().takeIf { state.isPullToRefreshEnabled }
LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) {
if (pullToRefreshState?.isRefreshing == true) {
viewModel.trySendAction(VaultItemListingsAction.RefreshPull)
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultItemListingEvent.NavigateBack -> onNavigateBack()
is VaultItemListingEvent.DismissPullToRefresh -> pullToRefreshState?.endRefresh()
is VaultItemListingEvent.NavigateToVaultItem -> {
onNavigateToVaultItem(event.id)
}
@ -99,6 +112,7 @@ fun VaultItemListingScreen(
VaultItemListingScaffold(
state = state,
pullToRefreshState = pullToRefreshState,
vaultItemListingHandlers = remember(viewModel) {
VaultItemListingHandlers.create(viewModel)
},
@ -132,6 +146,7 @@ private fun VaultItemListingDialogs(
@Composable
private fun VaultItemListingScaffold(
state: VaultItemListingState,
pullToRefreshState: PullToRefreshState?,
vaultItemListingHandlers: VaultItemListingHandlers,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -179,6 +194,7 @@ private fun VaultItemListingScaffold(
}
}
},
pullToRefreshState = pullToRefreshState,
) { paddingValues ->
val modifier = Modifier
.fillMaxSize()

View file

@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -59,11 +60,18 @@ class VaultItemListingViewModel @Inject constructor(
baseWebSendUrl = environmentRepository.environment.environmentUrlData.baseWebSendUrl,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
dialogState = null,
),
) {
init {
settingsRepository
.getPullToRefreshEnabledFlow()
.map { VaultItemListingsAction.Internal.PullToRefreshEnableReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isIconLoadingDisabledFlow
.onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) }
@ -86,6 +94,7 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.ItemClick -> handleItemClick(action)
is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick()
is VaultItemListingsAction.RefreshClick -> handleRefreshClick()
is VaultItemListingsAction.RefreshPull -> handleRefreshPull()
is VaultItemListingsAction.Internal -> handleInternalAction(action)
}
}
@ -95,6 +104,12 @@ class VaultItemListingViewModel @Inject constructor(
vaultRepository.sync()
}
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 handleCopySendUrlClick(action: ListingItemOverflowAction.SendAction.CopyUrlClick) {
clipboardManager.setText(text = action.sendUrl)
}
@ -222,6 +237,10 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleInternalAction(action: VaultItemListingsAction.Internal) {
when (action) {
is VaultItemListingsAction.Internal.PullToRefreshEnableReceive -> {
handlePullToRefreshEnableReceive(action)
}
is VaultItemListingsAction.Internal.DeleteSendResultReceive -> {
handleDeleteSendResultReceive(action)
}
@ -237,6 +256,14 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handlePullToRefreshEnableReceive(
action: VaultItemListingsAction.Internal.PullToRefreshEnableReceive,
) {
mutableStateFlow.update {
it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled)
}
}
private fun handleDeleteSendResultReceive(
action: VaultItemListingsAction.Internal.DeleteSendResultReceive,
) {
@ -326,10 +353,12 @@ class VaultItemListingViewModel @Inject constructor(
)
}
}
sendEvent(VaultItemListingEvent.DismissPullToRefresh)
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
sendEvent(VaultItemListingEvent.DismissPullToRefresh)
}
private fun vaultLoadingReceive() {
@ -351,6 +380,7 @@ class VaultItemListingViewModel @Inject constructor(
)
}
}
sendEvent(VaultItemListingEvent.DismissPullToRefresh)
}
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
@ -413,8 +443,15 @@ data class VaultItemListingState(
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
val dialogState: DialogState?,
private val isPullToRefreshSettingEnabled: Boolean,
) {
/**
* Indicates that the pull-to-refresh should be enabled in the UI.
*/
val isPullToRefreshEnabled: Boolean
get() = isPullToRefreshSettingEnabled && viewState.isPullToRefreshEnabled
/**
* Represents the current state of any dialogs on the screen.
*/
@ -442,17 +479,25 @@ data class VaultItemListingState(
* Represents the specific view states for the [VaultItemListingScreen].
*/
sealed class ViewState {
/**
* Indicates the pull-to-refresh feature should be available during the current state.
*/
abstract val isPullToRefreshEnabled: Boolean
/**
* Loading state for the [VaultItemListingScreen],
* signifying that the content is being processed.
*/
data object Loading : ViewState()
data object Loading : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = false
}
/**
* Represents a state where the [VaultItemListingScreen] has no items to display.
*/
data object NoItems : ViewState()
data object NoItems : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
/**
* Content state for the [VaultItemListingScreen] showing the actual content or items.
@ -461,7 +506,9 @@ data class VaultItemListingState(
*/
data class Content(
val displayItemList: List<DisplayItem>,
) : ViewState()
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
/**
* Represents an error state for the [VaultItemListingScreen].
@ -470,7 +517,9 @@ data class VaultItemListingState(
*/
data class Error(
val message: Text,
) : ViewState()
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
}
/**
@ -609,6 +658,10 @@ data class VaultItemListingState(
* Models events for the [VaultItemListingScreen].
*/
sealed class VaultItemListingEvent {
/**
* Dismisses the pull-to-refresh indicator.
*/
data object DismissPullToRefresh : VaultItemListingEvent()
/**
* Navigates to the Create Account screen.
@ -711,10 +764,19 @@ sealed class VaultItemListingsAction {
*/
data class ItemClick(val id: String) : VaultItemListingsAction()
/**
* User has triggered a pull to refresh.
*/
data object RefreshPull : VaultItemListingsAction()
/**
* Models actions that the [VaultItemListingViewModel] itself might send.
*/
sealed class Internal : VaultItemListingsAction() {
/**
* Indicates that the pull to refresh feature toggle has changed.
*/
data class PullToRefreshEnableReceive(val isPullToRefreshEnabled: Boolean) : Internal()
/**
* Indicates a result for deleting the send has been received.

View file

@ -699,6 +699,7 @@ private val DEFAULT_STATE = VaultItemListingState(
baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isPullToRefreshSettingEnabled = false,
dialogState = null,
)

View file

@ -66,10 +66,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { environmentStateFlow } returns mockk()
}
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
every { isIconLoadingDisabled } returns false
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
}
private val initialState = createVaultItemListingState()
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
@ -350,25 +352,27 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() =
runTest {
setupMockUri()
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
),
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
@ -384,17 +388,19 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
viewModel.stateFlow.value,
@ -404,17 +410,20 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow Loaded with trash items should update ViewState to NoItems`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
@ -508,14 +517,16 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow Error without data should update state to Error`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
error = IllegalStateException(),
),
val dataState = DataState.Error<VaultData>(
error = IllegalStateException(),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Error(
@ -530,20 +541,22 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fun `vaultDataStateFlow Error with data should update state to Content`() = runTest {
setupMockUri()
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
val dataState = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
@ -560,20 +573,22 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow Error with empty data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
error = IllegalStateException(),
val dataState = DataState.Error(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
error = IllegalStateException(),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
@ -584,20 +599,22 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow Error with trash data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
val dataState = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
@ -608,12 +625,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(),
)
val dataState = DataState.NoNetwork<VaultData>()
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Error(
@ -630,19 +649,21 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest {
setupMockUri()
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
val dataState = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
@ -659,19 +680,21 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow NoNetwork with empty data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
val dataState = DataState.NoNetwork(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
@ -682,19 +705,21 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Test
fun `vaultDataStateFlow NoNetwork with trash data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
val dataState = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VaultItemListingEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
@ -713,6 +738,33 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled)
}
@Test
fun `RefreshPull should call vault repository sync`() {
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(VaultItemListingsAction.RefreshPull)
verify(exactly = 1) {
vaultRepository.sync()
}
}
@Test
fun `PullToRefreshEnableReceive should update isPullToRefreshEnabled`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(
VaultItemListingsAction.Internal.PullToRefreshEnableReceive(
isPullToRefreshEnabled = true,
),
)
assertEquals(
initialState.copy(isPullToRefreshSettingEnabled = true),
viewModel.stateFlow.value,
)
}
@Suppress("CyclomaticComplexMethod")
private fun createSavedStateHandleWithVaultItemListingType(
vaultItemListingType: VaultItemListingType,
@ -779,6 +831,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
baseWebSendUrl = Environment.Us.environmentUrlData.baseWebSendUrl,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPullToRefreshSettingEnabled = false,
dialogState = null,
)
}