mirror of
https://github.com/bitwarden/android.git
synced 2024-11-23 09:56:11 +03:00
BIT-1268: Pull-to-refresh for sends screen (#618)
This commit is contained in:
parent
80084ff0fb
commit
0975ab9c7b
4 changed files with 170 additions and 46 deletions
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue