From 84a983e7551b7069abcf74ecee39911f1f8f5e99 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 29 Jan 2024 22:21:39 -0600 Subject: [PATCH] BIT-1574: Add pull to refresh behavior (#861) --- .../pendingrequests/PendingRequestsScreen.kt | 13 +++ .../PendingRequestsViewModel.kt | 85 +++++++++++++++++-- .../PendingRequestsScreenTest.kt | 7 +- .../PendingRequestsViewModelTest.kt | 49 ++++++++--- 4 files changed, 132 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt index 494b410ea..261862b75 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt @@ -21,12 +21,15 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +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 import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,8 +67,17 @@ fun PendingRequestsScreen( val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current val resources = context.resources + val pullToRefreshState by rememberUpdatedState( + newValue = rememberPullToRefreshState().takeIf { state.isPullToRefreshEnabled }, + ) + LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) { + if (pullToRefreshState?.isRefreshing == true) { + viewModel.trySendAction(PendingRequestsAction.RefreshPull) + } + } EventsEffect(viewModel = viewModel) { event -> when (event) { + PendingRequestsEvent.DismissPullToRefresh -> pullToRefreshState?.endRefresh() PendingRequestsEvent.NavigateBack -> onNavigateBack() is PendingRequestsEvent.NavigateToLoginApproval -> { onNavigateToLoginApproval(event.fingerprint) @@ -103,6 +115,7 @@ fun PendingRequestsScreen( }, ) }, + pullToRefreshState = pullToRefreshState, ) { innerPadding -> when (val viewState = state.viewState) { is PendingRequestsState.ViewState.Content -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt index f6264c7ae..4ef0f1a05 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -6,10 +6,14 @@ import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.isOverFiveMinutesOld 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 import kotlinx.parcelize.Parcelize @@ -25,11 +29,13 @@ private const val KEY_STATE = "state" @HiltViewModel class PendingRequestsViewModel @Inject constructor( private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState( authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Loading, + isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, ), ) { private val dateTimeFormatter @@ -39,6 +45,11 @@ class PendingRequestsViewModel @Inject constructor( init { updateAuthRequestList() + settingsRepository + .getPullToRefreshEnabledFlow() + .map { PendingRequestsAction.Internal.PullToRefreshEnableReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: PendingRequestsAction) { @@ -46,13 +57,12 @@ class PendingRequestsViewModel @Inject constructor( PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.DeclineAllRequestsConfirm -> handleDeclineAllRequestsConfirmed() PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed() + PendingRequestsAction.RefreshPull -> handleRefreshPull() is PendingRequestsAction.PendingRequestRowClick -> { handlePendingRequestRowClicked(action) } - is PendingRequestsAction.Internal.AuthRequestsResultReceive -> { - handleAuthRequestsResultReceived(action) - } + is PendingRequestsAction.Internal -> handleInternalAction(action) } } @@ -83,12 +93,36 @@ class PendingRequestsViewModel @Inject constructor( updateAuthRequestList() } + private fun handleRefreshPull() { + updateAuthRequestList() + } + private fun handlePendingRequestRowClicked( action: PendingRequestsAction.PendingRequestRowClick, ) { sendEvent(PendingRequestsEvent.NavigateToLoginApproval(action.fingerprint)) } + private fun handleInternalAction(action: PendingRequestsAction.Internal) { + when (action) { + is PendingRequestsAction.Internal.PullToRefreshEnableReceive -> { + handlePullToRefreshEnableReceive(action) + } + + is PendingRequestsAction.Internal.AuthRequestsResultReceive -> { + handleAuthRequestsResultReceived(action) + } + } + } + + private fun handlePullToRefreshEnableReceive( + action: PendingRequestsAction.Internal.PullToRefreshEnableReceive, + ) { + mutableStateFlow.update { + it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled) + } + } + private fun handleAuthRequestsResultReceived( action: PendingRequestsAction.Internal.AuthRequestsResultReceive, ) { @@ -135,10 +169,10 @@ class PendingRequestsViewModel @Inject constructor( } } } + sendEvent(PendingRequestsEvent.DismissPullToRefresh) } private fun updateAuthRequestList() { - // TODO BIT-1574: Display pull to refresh viewModelScope.launch { trySendAction( PendingRequestsAction.Internal.AuthRequestsResultReceive( @@ -156,12 +190,24 @@ class PendingRequestsViewModel @Inject constructor( data class PendingRequestsState( val authRequests: List, val viewState: ViewState, + private val isPullToRefreshSettingEnabled: Boolean, ) : Parcelable { + /** + * 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 [PendingRequestsScreen]. */ @Parcelize sealed class ViewState : Parcelable { + /** + * Indicates the pull-to-refresh feature should be available during the current state. + */ + abstract val isPullToRefreshEnabled: Boolean + /** * Content state for the [PendingRequestsScreen] listing pending request items. */ @@ -169,6 +215,8 @@ data class PendingRequestsState( data class Content( val requests: List, ) : ViewState() { + override val isPullToRefreshEnabled: Boolean get() = true + /** * Models the data for a pending login request. */ @@ -184,21 +232,27 @@ data class PendingRequestsState( * Represents the state wherein there are no pending login requests. */ @Parcelize - data object Empty : ViewState() + data object Empty : ViewState() { + override val isPullToRefreshEnabled: Boolean get() = true + } /** * Represents a state where the [PendingRequestsScreen] is unable to display data due to an * error retrieving it. */ @Parcelize - data object Error : ViewState() + data object Error : ViewState() { + override val isPullToRefreshEnabled: Boolean get() = true + } /** * Loading state for the [PendingRequestsScreen], signifying that the content is being * processed. */ @Parcelize - data object Loading : ViewState() + data object Loading : ViewState() { + override val isPullToRefreshEnabled: Boolean get() = false + } } } @@ -206,6 +260,11 @@ data class PendingRequestsState( * Models events for the delete account screen. */ sealed class PendingRequestsEvent { + /** + * Dismisses the pull-to-refresh indicator. + */ + data object DismissPullToRefresh : PendingRequestsEvent() + /** * Navigates back. */ @@ -253,10 +312,22 @@ sealed class PendingRequestsAction { val fingerprint: String, ) : PendingRequestsAction() + /** + * User has triggered a pull to refresh. + */ + data object RefreshPull : PendingRequestsAction() + /** * Models actions sent by the view model itself. */ sealed class Internal : PendingRequestsAction() { + /** + * Indicates that the pull to refresh feature toggle has changed. + */ + data class PullToRefreshEnableReceive( + val isPullToRefreshEnabled: Boolean, + ) : Internal() + /** * Indicates that a new auth requests result has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt index 175865991..858db2b93 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt @@ -55,8 +55,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { @Test fun `on decline all requests confirmation should send DeclineAllRequestsConfirm`() = runTest { // set content so the Decline all requests button appears - mutableStateFlow.value = PendingRequestsState( - authRequests = listOf(), + mutableStateFlow.value = DEFAULT_STATE.copy( viewState = PendingRequestsState.ViewState.Content( requests = listOf( PendingRequestsState.ViewState.Content.PendingLoginRequest( @@ -87,8 +86,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { @Test fun `on decline all requests cancel should hide confirmation dialog`() = runTest { // set content so the Decline all requests button appears - mutableStateFlow.value = PendingRequestsState( - authRequests = listOf(), + mutableStateFlow.value = DEFAULT_STATE.copy( viewState = PendingRequestsState.ViewState.Content( requests = listOf( PendingRequestsState.ViewState.Content.PendingLoginRequest( @@ -120,6 +118,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Loading, + isPullToRefreshSettingEnabled = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt index bbb86716a..329378fed 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -6,10 +6,15 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.awaits import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -21,7 +26,14 @@ import java.util.TimeZone class PendingRequestsViewModelTest : BaseViewModelTest() { - private val authRepository = mockk() + private val authRepository = mockk { + // This is called during init, anything that cares about this will handle it + coEvery { getAuthRequests() } just awaits + } + private val mutablePullToRefreshStateFlow = MutableStateFlow(false) + private val settingsRepository = mockk { + every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow + } @BeforeEach fun setup() { @@ -159,11 +171,6 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { @Test fun `on CloseClick should emit NavigateBack`() = runTest { - coEvery { - authRepository.getAuthRequests() - } returns AuthRequestsResult.Success( - authRequests = emptyList(), - ) val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(PendingRequestsAction.CloseClick) @@ -171,14 +178,32 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Test + fun `on RefreshPull should make auth request`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(PendingRequestsAction.RefreshPull) + coVerify(exactly = 2) { + // This should be called twice since we also call it on init + authRepository.getAuthRequests() + } + } + + @Test + fun `updates to pull-to-refresh enabled state should update isPullToRefreshEnabled`() = + runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutablePullToRefreshStateFlow.value = true + assertEquals(DEFAULT_STATE.copy(isPullToRefreshSettingEnabled = true), awaitItem()) + mutablePullToRefreshStateFlow.value = false + assertEquals(DEFAULT_STATE.copy(isPullToRefreshSettingEnabled = false), awaitItem()) + } + } + @Test fun `on PendingRequestRowClick should emit NavigateToLoginApproval`() = runTest { val fingerprint = "fingerprint" - coEvery { - authRepository.getAuthRequests() - } returns AuthRequestsResult.Success( - authRequests = emptyList(), - ) val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction( @@ -362,6 +387,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { state: PendingRequestsState? = DEFAULT_STATE, ): PendingRequestsViewModel = PendingRequestsViewModel( authRepository = authRepository, + settingsRepository = settingsRepository, savedStateHandle = SavedStateHandle().apply { set("state", state) }, ) @@ -369,6 +395,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Empty, + isPullToRefreshSettingEnabled = false, ) } }