mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 10:48:47 +03:00
BIT-461: Add pull-to-refresh to vault screen (#619)
This commit is contained in:
parent
0f0fe81f41
commit
0f2e5359d2
4 changed files with 128 additions and 6 deletions
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue