diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index c57eec77f..2771584f7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 2d02704a1..886722c9e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -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( // 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, ) : 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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index a51a2ed35..5c23476bd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -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 = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index 8348874ee..da7ff199d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -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.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() - val sendData = mockk { - 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() + val sendData = mockk { + 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, )