BIT-1268: Pull-to-refresh for sends screen (#618)

This commit is contained in:
David Perez 2024-01-15 10:40:03 -06:00 committed by Álison Fernandes
parent 80084ff0fb
commit 0975ab9c7b
4 changed files with 170 additions and 46 deletions

View file

@ -12,8 +12,10 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@ -55,8 +57,18 @@ fun SendScreen(
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val pullToRefreshState = rememberPullToRefreshState()
.takeIf { state.isPullToRefreshEnabled }
LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) {
if (pullToRefreshState?.isRefreshing == true) {
viewModel.trySendAction(SendAction.RefreshPull)
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is SendEvent.DismissPullToRefresh -> pullToRefreshState?.endRefresh()
is SendEvent.NavigateNewSend -> onNavigateToAddSend()
is SendEvent.NavigateToEditSend -> onNavigateToEditSend(event.sendId)
@ -146,6 +158,7 @@ fun SendScreen(
}
}
},
pullToRefreshState = pullToRefreshState,
) { padding ->
val modifier = Modifier
.imePadding()

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -40,6 +41,7 @@ class SendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepo: EnvironmentRepository,
private val settingsRepo: SettingsRepository,
private val vaultRepo: VaultRepository,
) : BaseViewModel<SendState, SendEvent, SendAction>(
// We load the state from the savedStateHandle for testing purposes.
@ -47,10 +49,16 @@ class SendViewModel @Inject constructor(
?: SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = settingsRepo.getPullToRefreshEnabledFlow().value,
),
) {
init {
settingsRepo
.getPullToRefreshEnabledFlow()
.map { SendAction.Internal.PullToRefreshEnableReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
vaultRepo
.sendDataStateFlow
.map { SendAction.Internal.SendDataReceive(it) }
@ -73,10 +81,15 @@ class SendViewModel @Inject constructor(
is SendAction.DeleteSendClick -> handleDeleteSendClick(action)
is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action)
SendAction.DismissDialog -> handleDismissDialog()
SendAction.RefreshPull -> handleRefreshPull()
is SendAction.Internal -> handleInternalAction(action)
}
private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) {
is SendAction.Internal.PullToRefreshEnableReceive -> {
handlePullToRefreshEnableReceive(action)
}
is SendAction.Internal.DeleteSendResultReceive -> handleDeleteSendResultReceive(action)
is SendAction.Internal.RemovePasswordSendResultReceive -> {
handleRemovePasswordSendResultReceive(action)
@ -85,6 +98,14 @@ class SendViewModel @Inject constructor(
is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
}
private fun handlePullToRefreshEnableReceive(
action: SendAction.Internal.PullToRefreshEnableReceive,
) {
mutableStateFlow.update {
it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled)
}
}
private fun handleDeleteSendResultReceive(action: SendAction.Internal.DeleteSendResultReceive) {
when (action.result) {
DeleteSendResult.Error -> {
@ -141,6 +162,7 @@ class SendViewModel @Inject constructor(
dialogState = null,
)
}
sendEvent(SendEvent.DismissPullToRefresh)
}
is DataState.Loaded -> {
@ -155,6 +177,7 @@ class SendViewModel @Inject constructor(
dialogState = null,
)
}
sendEvent(SendEvent.DismissPullToRefresh)
}
DataState.Loading -> {
@ -174,6 +197,7 @@ class SendViewModel @Inject constructor(
dialogState = null,
)
}
sendEvent(SendEvent.DismissPullToRefresh)
}
is DataState.Pending -> {
@ -269,6 +293,12 @@ class SendViewModel @Inject constructor(
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleRefreshPull() {
// The Pull-To-Refresh composable is already in the refreshing state.
// We will reset that state when sendDataStateFlow emits later on.
vaultRepo.sync()
}
}
/**
@ -278,12 +308,24 @@ class SendViewModel @Inject constructor(
data class SendState(
val viewState: ViewState,
val dialogState: DialogState?,
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 send screen.
*/
sealed class ViewState : Parcelable {
/**
* Indicates the pull-to-refresh feature should be available during the current state.
*/
abstract val isPullToRefreshEnabled: Boolean
/**
* Indicates if the FAB should be displayed.
*/
@ -298,6 +340,7 @@ data class SendState(
val fileTypeCount: Int,
val sendItems: List<SendItem>,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
override val shouldDisplayFab: Boolean get() = true
/**
@ -328,6 +371,7 @@ data class SendState(
*/
@Parcelize
data object Empty : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
override val shouldDisplayFab: Boolean get() = true
}
@ -338,6 +382,7 @@ data class SendState(
data class Error(
val message: Text,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
override val shouldDisplayFab: Boolean get() = false
}
@ -346,6 +391,7 @@ data class SendState(
*/
@Parcelize
data object Loading : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = false
override val shouldDisplayFab: Boolean get() = false
}
}
@ -458,10 +504,20 @@ sealed class SendAction {
*/
data object DismissDialog : SendAction()
/**
* User has triggered a pull to refresh.
*/
data object RefreshPull : SendAction()
/**
* Models actions that the [SendViewModel] itself will send.
*/
sealed class Internal : SendAction() {
/**
* Indicates that the pull to refresh feature toggle has changed.
*/
data class PullToRefreshEnableReceive(val isPullToRefreshEnabled: Boolean) : Internal()
/**
* Indicates a result for deleting the send has been received.
*/
@ -487,6 +543,11 @@ sealed class SendAction {
* Models events for the send screen.
*/
sealed class SendEvent {
/**
* Dismisses the pull-to-refresh indicator.
*/
data object DismissPullToRefresh : SendEvent()
/**
* Navigate to the new send screen.
*/

View file

@ -639,6 +639,7 @@ class SendScreenTest : BaseComposeTest() {
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = false,
)
private val DEFAULT_SEND_ITEM: SendState.ViewState.Content.SendItem =

View file

@ -5,6 +5,7 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
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.platform.repository.util.baseWebSendUrl
@ -33,12 +34,17 @@ import org.junit.jupiter.api.Test
class SendViewModelTest : BaseViewModelTest() {
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
private val mutableSendDataFlow = MutableStateFlow<DataState<SendData>>(DataState.Loading)
private val clipboardManager: BitwardenClipboardManager = mockk()
private val environmentRepo: EnvironmentRepository = mockk {
every { environment } returns Environment.Us
}
private val settingsRepo: SettingsRepository = mockk {
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
}
private val vaultRepo: VaultRepository = mockk {
every { sendDataStateFlow } returns mutableSendDataFlow
}
@ -300,42 +306,51 @@ class SendViewModelTest : BaseViewModelTest() {
assertEquals(initialState.copy(dialogState = null), viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `VaultRepository SendData Error should update view state to Error`() {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
fun `VaultRepository SendData Error should update view state to Error and emit DismissPullToRefresh`() =
runTest {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
mutableSendDataFlow.value = DataState.Error(Throwable("Fail"))
viewModel.eventFlow.test {
mutableSendDataFlow.value = DataState.Error(Throwable("Fail"))
assertEquals(SendEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
SendState(
viewState = SendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
assertEquals(
DEFAULT_STATE.copy(
viewState = SendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
dialogState = null,
),
dialogState = null,
),
viewModel.stateFlow.value,
)
}
@Test
fun `VaultRepository SendData Loaded should update view state`() {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every {
toViewState(Environment.Us.environmentUrlData.baseWebSendUrl)
} returns viewState
viewModel.stateFlow.value,
)
}
mutableSendDataFlow.value = DataState.Loaded(sendData)
@Test
fun `VaultRepository SendData Loaded should update view state and emit DismissPullToRefresh`() =
runTest {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every {
toViewState(Environment.Us.environmentUrlData.baseWebSendUrl)
} returns viewState
}
assertEquals(
SendState(viewState = viewState, dialogState = null),
viewModel.stateFlow.value,
)
}
viewModel.eventFlow.test {
mutableSendDataFlow.value = DataState.Loaded(sendData)
assertEquals(SendEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
DEFAULT_STATE.copy(viewState = viewState, dialogState = null),
viewModel.stateFlow.value,
)
}
@Test
fun `VaultRepository SendData Loading should update view state to Loading`() {
@ -345,30 +360,35 @@ class SendViewModelTest : BaseViewModelTest() {
mutableSendDataFlow.value = DataState.Loading
assertEquals(
SendState(viewState = SendState.ViewState.Loading, dialogState = dialogState),
DEFAULT_STATE.copy(viewState = SendState.ViewState.Loading, dialogState = dialogState),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `VaultRepository SendData NoNetwork should update view state to Error`() {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
fun `VaultRepository SendData NoNetwork should update view state to Error and emit DismissPullToRefresh`() =
runTest {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())
val viewModel = createViewModel(state = DEFAULT_STATE.copy(dialogState = dialogState))
mutableSendDataFlow.value = DataState.NoNetwork()
viewModel.eventFlow.test {
mutableSendDataFlow.value = DataState.NoNetwork()
assertEquals(SendEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
SendState(
viewState = SendState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
assertEquals(
DEFAULT_STATE.copy(
viewState = SendState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
dialogState = null,
),
dialogState = null,
),
viewModel.stateFlow.value,
)
}
viewModel.stateFlow.value,
)
}
@Test
fun `VaultRepository SendData Pending should update view state`() {
@ -384,7 +404,33 @@ class SendViewModelTest : BaseViewModelTest() {
mutableSendDataFlow.value = DataState.Pending(sendData)
assertEquals(
SendState(viewState = viewState, dialogState = dialogState),
DEFAULT_STATE.copy(viewState = viewState, dialogState = dialogState),
viewModel.stateFlow.value,
)
}
@Test
fun `RefreshPull should call vault repository sync`() {
every { vaultRepo.sync() } just runs
val viewModel = createViewModel()
viewModel.trySendAction(SendAction.RefreshPull)
verify(exactly = 1) {
vaultRepo.sync()
}
}
@Test
fun `PullToRefreshEnableReceive should update isPullToRefreshEnabled`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
SendAction.Internal.PullToRefreshEnableReceive(isPullToRefreshEnabled = true),
)
assertEquals(
DEFAULT_STATE.copy(isPullToRefreshSettingEnabled = true),
viewModel.stateFlow.value,
)
}
@ -393,6 +439,7 @@ class SendViewModelTest : BaseViewModelTest() {
state: SendState? = null,
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
environmentRepository: EnvironmentRepository = environmentRepo,
settingsRepository: SettingsRepository = settingsRepo,
vaultRepository: VaultRepository = vaultRepo,
): SendViewModel = SendViewModel(
savedStateHandle = SavedStateHandle().apply {
@ -400,6 +447,7 @@ class SendViewModelTest : BaseViewModelTest() {
},
clipboardManager = bitwardenClipboardManager,
environmentRepo = environmentRepository,
settingsRepo = settingsRepository,
vaultRepo = vaultRepository,
)
}
@ -410,4 +458,5 @@ private const val SEND_DATA_EXTENSIONS_PATH: String =
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = false,
)