mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
BIT-1574: Add pull to refresh behavior (#861)
This commit is contained in:
parent
8489bd1476
commit
84a983e755
4 changed files with 132 additions and 22 deletions
|
@ -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 -> {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue