BIT-461: Add pull-to-refresh to vault screen (#619)

This commit is contained in:
David Perez 2024-01-15 13:21:28 -06:00 committed by Álison Fernandes
parent 0f0fe81f41
commit 0f2e5359d2
4 changed files with 128 additions and 6 deletions

View file

@ -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 {

View file

@ -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<VaultState, VaultEvent, VaultAction>(
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<VaultData>) {
@ -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<VaultData>) {
@ -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.
*/

View file

@ -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(

View file

@ -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<UserState?>(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,
)