BIT-1574: Add pull to refresh behavior (#861)

This commit is contained in:
David Perez 2024-01-29 22:21:39 -06:00 committed by Álison Fernandes
parent 8489bd1476
commit 84a983e755
4 changed files with 132 additions and 22 deletions

View file

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

View file

@ -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<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>(
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<AuthRequest>,
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<PendingLoginRequest>,
) : 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.
*/

View file

@ -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,
)
}
}

View file

@ -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<AuthRepository>()
private val authRepository = mockk<AuthRepository> {
// 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<SettingsRepository> {
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,
)
}
}