Adding the UI and ViewModel (#700)

This commit is contained in:
Oleg Semenenko 2024-01-21 10:52:28 -06:00 committed by Álison Fernandes
parent cd1d326d45
commit ffba00bf83
7 changed files with 1705 additions and 51 deletions

View file

@ -0,0 +1,179 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenIcon
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* The verification code item displayed to the user.
*
* @param authCode The code for the item.
* @param label The label for the item.
* @param periodSeconds The times span where the code is valid.
* @param timeLeftSeconds The seconds remaining until a new code is needed.
* @param startIcon The leading icon for the item.
* @param onCopyClick The lambda function to be invoked when the copy button is clicked.
* @param onItemClick The lambda function to be invoked when the item is clicked.
* @param modifier The modifier for the item.
* @param supportingLabel The supporting label for the item.
*/
@Suppress("LongMethod", "MagicNumber")
@Composable
fun VaultVerificationCodeItem(
authCode: String,
label: String,
periodSeconds: Int,
timeLeftSeconds: Int,
startIcon: IconData,
onCopyClick: () -> Unit,
onItemClick: () -> Unit,
modifier: Modifier = Modifier,
supportingLabel: String? = null,
) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onItemClick,
)
.defaultMinSize(minHeight = 72.dp)
.padding(vertical = 8.dp)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
BitwardenIcon(
iconData = startIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.weight(1f),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
supportingLabel?.let {
Text(
text = it,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
CircularIndicator(
timeLeftSeconds = timeLeftSeconds,
periodSeconds = periodSeconds,
)
Text(
text = authCode.chunked(3).joinToString(" "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(
onClick = onCopyClick,
) {
Icon(
painter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
}
@Composable
private fun CircularIndicator(
timeLeftSeconds: Int,
periodSeconds: Int,
) {
val progressAnimate by animateFloatAsState(
targetValue = timeLeftSeconds.toFloat() / periodSeconds,
animationSpec = tween(
durationMillis = periodSeconds,
delayMillis = 0,
easing = LinearOutSlowInEasing,
),
)
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { progressAnimate },
modifier = Modifier.size(size = 50.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
)
Text(
text = timeLeftSeconds.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Suppress("MagicNumber")
@Preview(showBackground = true)
@Composable
private fun VerificationCodeItem_preview() {
BitwardenTheme {
VaultVerificationCodeItem(
startIcon = IconData.Local(R.drawable.ic_login_item),
label = "Sample Label",
supportingLabel = "Supporting Label",
authCode = "1234567890".chunked(3).joinToString(" "),
timeLeftSeconds = 15,
periodSeconds = 30,
onCopyClick = {},
onItemClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}

View file

@ -1,49 +1,80 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.handlers.VerificationCodeHandlers
import kotlinx.collections.immutable.persistentListOf
/**
* Displays the verification codes to the user.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerificationCodeScreen(
viewModel: VerificationCodeViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToVaultItemScreen: (String) -> Unit,
viewModel: VerificationCodeViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val verificationCodeHandler = remember(viewModel) {
VerificationCodeHandlers.create(viewModel)
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VerificationCodeEvent.NavigateBack -> onNavigateBack.invoke()
is VerificationCodeEvent.NavigateToVaultItem -> onNavigateToVaultItemScreen(event.id)
val pullToRefreshState = rememberPullToRefreshState().takeIf { state.isPullToRefreshEnabled }
LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) {
if (pullToRefreshState?.isRefreshing == true) {
viewModel.trySendAction(VerificationCodeAction.RefreshPull)
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VerificationCodeEvent.DismissPullToRefresh -> pullToRefreshState?.endRefresh()
is VerificationCodeEvent.NavigateBack -> onNavigateBack()
is VerificationCodeEvent.NavigateToVaultItem -> onNavigateToVaultItemScreen(event.id)
is VerificationCodeEvent.NavigateToVaultSearchScreen -> {
showNotYetImplementedToast(context = context)
}
}
}
VerificationCodeDialogs(dialogState = state.dialogState)
@OptIn(ExperimentalMaterial3Api::class)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -51,25 +82,113 @@ fun VerificationCodeScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.verification_codes),
title = stringResource(id = R.string.verification_code),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VerificationCodeAction.BackClick) }
onNavigationIconClick = verificationCodeHandler.backClick,
actions = {
BitwardenSearchActionItem(
contentDescription = stringResource(id = R.string.search_vault),
onClick = verificationCodeHandler.searchIconClick,
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.sync),
onClick = verificationCodeHandler.syncClick,
),
OverflowMenuItemData(
text = stringResource(id = R.string.lock),
onClick = verificationCodeHandler.lockClick,
),
),
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Not yet implemented")
pullToRefreshState = pullToRefreshState,
) { paddingValues ->
val modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
when (val viewState = state.viewState) {
is VerificationCodeState.ViewState.Content -> {
VerificationCodeContent(
items = viewState.verificationCodeDisplayItems,
onCopyClick = verificationCodeHandler.copyClick,
itemClick = verificationCodeHandler.itemClick,
modifier = modifier,
)
}
is VerificationCodeState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message.invoke(),
onTryAgainClick = verificationCodeHandler.refreshClick,
modifier = modifier,
)
}
is VerificationCodeState.ViewState.Loading -> {
BitwardenLoadingContent(modifier = modifier)
}
is VerificationCodeState.ViewState.NoItems -> Unit
}
}
}
@Composable
private fun VerificationCodeDialogs(
dialogState: VerificationCodeState.DialogState?,
) {
when (dialogState) {
is VerificationCodeState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.message),
)
null -> Unit
}
}
@Composable
private fun VerificationCodeContent(
items: List<VerificationCodeDisplayItem>,
itemClick: (id: String) -> Unit,
onCopyClick: (text: String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
) {
item {
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.items),
supportingLabel = items.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(items) {
VaultVerificationCodeItem(
startIcon = it.startIcon,
label = it.label,
supportingLabel = it.supportingLabel,
timeLeftSeconds = it.timeLeftSeconds,
periodSeconds = it.periodSeconds,
authCode = it.authCode,
onCopyClick = { onCopyClick(it.authCode) },
onItemClick = {
itemClick(it.id)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
}

View file

@ -1,58 +1,299 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherType
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.baseIconUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
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.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.toVerificationCodeViewState
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.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Handles [VerificationCodeAction],
* and launches [VerificationCodeEvent] for the [VerificationCodeScreen].
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class VerificationCodeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VerificationCodeState, VerificationCodeEvent, VerificationCodeAction>(
initialState = savedStateHandle[KEY_STATE]
?: VerificationCodeState(
viewState = VerificationCodeState.ViewState.Empty,
),
initialState = run {
VerificationCodeState(
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
vaultFilterType = vaultRepository.vaultFilterType,
viewState = VerificationCodeState.ViewState.Loading,
dialogState = null,
)
},
) {
init {
settingsRepository
.getPullToRefreshEnabledFlow()
.map { VerificationCodeAction.Internal.PullToRefreshEnableReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isIconLoadingDisabledFlow
.map { VerificationCodeAction.Internal.IconLoadingSettingReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
vaultRepository
.vaultDataStateFlow
.onEach { sendAction(VerificationCodeAction.Internal.VaultDataReceive(vaultData = it)) }
.launchIn(viewModelScope)
}
override fun handleAction(action: VerificationCodeAction) {
when (action) {
is VerificationCodeAction.BackClick -> handleBackClick()
is VerificationCodeAction.CopyClick -> handleCopyClick(action)
is VerificationCodeAction.ItemClick -> handleItemClick(action)
is VerificationCodeAction.LockClick -> handleLockClick()
is VerificationCodeAction.RefreshClick -> handleRefreshClick()
is VerificationCodeAction.RefreshPull -> handleRefreshPull()
is VerificationCodeAction.SearchIconClick -> handleSearchIconClick()
is VerificationCodeAction.SyncClick -> handleSyncClick()
is VerificationCodeAction.Internal -> handleInternalAction(action)
}
}
//region VerificationCode Handlers
private fun handleBackClick() {
sendEvent(
event = VerificationCodeEvent.NavigateBack,
)
}
private fun handleCopyClick(action: VerificationCodeAction.CopyClick) {
clipboardManager.setText(text = action.text)
}
private fun handleItemClick(action: VerificationCodeAction.ItemClick) {
sendEvent(
VerificationCodeEvent.NavigateToVaultItem(action.id),
)
}
private fun handleLockClick() {
vaultRepository.lockVaultForCurrentUser()
}
private fun handleRefreshClick() {
vaultRepository.sync()
}
private fun handleRefreshPull() {
// The Pull-To-Refresh composable is already in the refreshing state.
// We will reset that state when sendDataStateFlow emits later on.
vaultRepository.sync()
}
private fun handleSearchIconClick() {
sendEvent(
event = VerificationCodeEvent.NavigateToVaultSearchScreen,
)
}
private fun handleSyncClick() {
mutableStateFlow.update {
it.copy(
dialogState = VerificationCodeState.DialogState.Loading(
message = R.string.syncing.asText(),
),
)
}
vaultRepository.sync()
}
private fun handleInternalAction(action: VerificationCodeAction.Internal) {
when (action) {
is VerificationCodeAction.Internal.IconLoadingSettingReceive ->
handleIconsSettingReceived(
action,
)
is VerificationCodeAction.Internal.PullToRefreshEnableReceive ->
handlePullToRefreshEnableReceive(
action,
)
is VerificationCodeAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
}
}
private fun handleIconsSettingReceived(
action: VerificationCodeAction.Internal.IconLoadingSettingReceive,
) {
mutableStateFlow.update {
it.copy(isIconLoadingDisabled = action.isIconLoadingDisabled)
}
vaultRepository.vaultDataStateFlow.value.data?.let { vaultData ->
updateStateWithVaultData(vaultData, clearDialogState = false)
}
}
private fun handlePullToRefreshEnableReceive(
action: VerificationCodeAction.Internal.PullToRefreshEnableReceive,
) {
mutableStateFlow.update {
it.copy(isPullToRefreshSettingEnabled = action.isPullToRefreshEnabled)
}
}
private fun handleVaultDataReceive(action: VerificationCodeAction.Internal.VaultDataReceive) {
updateViewState(action.vaultData)
}
//endregion VerificationCode Handlers
private fun updateViewState(vaultData: DataState<VaultData>) {
when (vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData)
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
is DataState.Loading -> vaultLoadingReceive()
is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData)
is DataState.Pending -> vaultPendingReceive(vaultData)
}
}
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
if (vaultData.data != null) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
} else {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = VerificationCodeState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
dialogState = null,
)
}
}
sendEvent(VerificationCodeEvent.DismissPullToRefresh)
}
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = false)
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
sendEvent(VerificationCodeEvent.DismissPullToRefresh)
}
private fun vaultLoadingReceive() {
mutableStateFlow.update { it.copy(viewState = VerificationCodeState.ViewState.Loading) }
}
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
if (vaultData.data != null) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
} else {
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
dialogState = null,
)
}
}
sendEvent(VerificationCodeEvent.DismissPullToRefresh)
}
private fun updateStateWithVaultData(
vaultData: VaultData,
clearDialogState: Boolean,
) {
val viewState = vaultData
.cipherViewList
.filter {
it.type == CipherType.LOGIN &&
!it.login?.totp.isNullOrBlank() &&
it.deletedDate == null
}
.toFilteredList(state.vaultFilterType)
.toVerificationCodeViewState(
baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled,
)
if (viewState is VerificationCodeState.ViewState.NoItems) {
sendEvent(VerificationCodeEvent.NavigateBack)
return
}
mutableStateFlow.update { state ->
state.copy(
viewState = viewState,
dialogState = state.dialogState.takeUnless { clearDialogState },
)
}
}
}
/**
* Models state of the verification code screen.
*
* @property viewState indicates what view state the screen is in.
*/
@Parcelize
data class VerificationCodeState(
val viewState: ViewState,
val vaultFilterType: VaultFilterType,
val isIconLoadingDisabled: Boolean,
val baseIconUrl: String,
val dialogState: DialogState?,
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 current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
/**
* Represents the specific view states for the [VerificationCodeScreen].
*/
@ -60,18 +301,71 @@ data class VerificationCodeState(
sealed class ViewState : Parcelable {
/**
* Represents an empty content state for the [VerificationCodeScreen].
* Indicates the pull-to-refresh feature should be available during the current state.
*/
@Parcelize
data object Empty : ViewState()
abstract val isPullToRefreshEnabled: Boolean
/**
* Represents a state where the [VerificationCodeScreen] has no items to display.
*/
data object NoItems : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
/**
* Loading state for the [VerificationCodeScreen],
* signifying that the content is being processed.
*/
data object Loading : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = false
}
/**
* Represents an error state for the [VerificationCodeScreen].
*
* @property message Error message to display.
*/
data class Error(
val message: Text,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
/**
* Content state for the [VerificationCodeScreen] showing the actual content or items.
*/
data class Content(
val verificationCodeDisplayItems: List<VerificationCodeDisplayItem>,
) : ViewState() {
override val isPullToRefreshEnabled: Boolean get() = true
}
}
}
/**
* The data for the verification code item to displayed.
*/
@Parcelize
data class VerificationCodeDisplayItem(
val id: String,
val label: String,
val supportingLabel: String?,
val timeLeftSeconds: Int,
val periodSeconds: Int,
val authCode: String,
val startIcon: IconData = IconData.Local(R.drawable.ic_login_item),
) : Parcelable
/**
* Models events for the [VerificationCodeScreen].
*/
sealed class VerificationCodeEvent {
/**
* Dismisses the pull-to-refresh indicator.
*/
data object DismissPullToRefresh : VerificationCodeEvent()
/**
* Navigate back.
*/
@ -83,6 +377,11 @@ sealed class VerificationCodeEvent {
* @property id the id of the item to navigate to.
*/
data class NavigateToVaultItem(val id: String) : VerificationCodeEvent()
/**
* Navigates to the VaultSearchScreen.
*/
data object NavigateToVaultSearchScreen : VerificationCodeEvent()
}
/**
@ -91,14 +390,71 @@ sealed class VerificationCodeEvent {
sealed class VerificationCodeAction {
/**
* Click the back button.
* User has clicked the back button.
*/
data object BackClick : VerificationCodeAction()
/**
* User has clicked the copy button.
*/
data class CopyClick(val text: String) : VerificationCodeAction()
/**
* Navigates to an item.
*
* @property id the id of the item to navigate to.
*/
data class ItemClick(val id: String) : VerificationCodeAction()
/**
* User has clicked the lock button.
*/
data object LockClick : VerificationCodeAction()
/**
* User has clicked the refresh button.
*/
data object RefreshClick : VerificationCodeAction()
/**
* User has triggered a pull to refresh.
*/
data object RefreshPull : VerificationCodeAction()
/**
* User has clicked the search icon.
*/
data object SearchIconClick : VerificationCodeAction()
/**
* User has clicked the refresh button.
*/
data object SyncClick : VerificationCodeAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : VerificationCodeAction() {
/**
* Indicates that the pull to refresh feature toggle has changed.
*/
data class PullToRefreshEnableReceive(
val isPullToRefreshEnabled: Boolean,
) : Internal()
/**
* Indicates the icon setting was received.
*/
data class IconLoadingSettingReceive(
val isIconLoadingDisabled: Boolean,
) : Internal()
/**
* Indicates a vault data was received.
*/
data class VaultDataReceive(
val vaultData: DataState<VaultData>,
) : Internal()
}
}

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode.handlers
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeAction
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeViewModel
/**
* A collection of handler functions for managing actions within the context of viewing a list of
* verification code items.
*/
data class VerificationCodeHandlers(
val backClick: () -> Unit,
val searchIconClick: () -> Unit,
val itemClick: (id: String) -> Unit,
val refreshClick: () -> Unit,
val syncClick: () -> Unit,
val lockClick: () -> Unit,
val copyClick: (text: String) -> Unit,
) {
companion object {
/**
* Creates an instance of [VerificationCodeHandlers] by binding actions to the provided
* [VerificationCodeViewModel].
*/
fun create(
viewModel: VerificationCodeViewModel,
): VerificationCodeHandlers =
VerificationCodeHandlers(
backClick = { viewModel.trySendAction(VerificationCodeAction.BackClick) },
searchIconClick = {
viewModel.trySendAction(VerificationCodeAction.SearchIconClick)
},
itemClick = { viewModel.trySendAction(VerificationCodeAction.ItemClick(it)) },
refreshClick = { viewModel.trySendAction(VerificationCodeAction.RefreshClick) },
syncClick = { viewModel.trySendAction(VerificationCodeAction.SyncClick) },
lockClick = { viewModel.trySendAction(VerificationCodeAction.LockClick) },
copyClick = { viewModel.trySendAction(VerificationCodeAction.CopyClick(it)) },
)
}
}

View file

@ -0,0 +1,55 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode.util
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeDisplayItem
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeState
/**
* Converts a list of [CipherView] to a list of [VerificationCodeDisplayItem].
*/
fun List<CipherView>.toVerificationCodeViewState(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
): VerificationCodeState.ViewState =
if (isNotEmpty()) {
VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = toDisplayItemList(
baseIconUrl = baseIconUrl,
isIconLoadingDisabled = isIconLoadingDisabled,
),
)
} else {
VerificationCodeState.ViewState.NoItems
}
private fun List<CipherView>.toDisplayItemList(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
): List<VerificationCodeDisplayItem> =
this.map {
it.toDisplayItem(
baseIconUrl = baseIconUrl,
isIconLoadingDisabled = isIconLoadingDisabled,
)
}
/**
* A function used to create a sample [VerificationCodeDisplayItem].
*/
fun CipherView.toDisplayItem(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
): VerificationCodeDisplayItem =
VerificationCodeDisplayItem(
id = id.orEmpty(),
authCode = "123456",
label = name,
supportingLabel = login?.username,
periodSeconds = 30,
timeLeftSeconds = 15,
startIcon = login?.uris.toLoginIconData(
baseIconUrl = baseIconUrl,
isIconLoadingDisabled = isIconLoadingDisabled,
),
)

View file

@ -0,0 +1,355 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
class VerificationCodeScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultItemId: String? = null
private val mutableEventFlow = bufferedMutableSharedFlow<VerificationCodeEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VerificationCodeViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VerificationCodeScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultItemScreen = { onNavigateToVaultItemId = it },
)
}
}
@Test
fun `NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(VerificationCodeEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToVaultItem event should call onNavigateToVaultItemScreen`() {
val id = "id4321"
mutableEventFlow.tryEmit(VerificationCodeEvent.NavigateToVaultItem(id = id))
assertEquals(id, onNavigateToVaultItemId)
}
@Test
fun `clicking back button should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "Back")
.performClick()
verify { viewModel.trySendAction(VerificationCodeAction.BackClick) }
}
@Test
fun `search icon click should send SearchIconClick action`() {
composeTestRule
.onNodeWithContentDescription("Search vault")
.performClick()
verify { viewModel.trySendAction(VerificationCodeAction.SearchIconClick) }
}
@Test
fun `refresh button click should send RefreshClick action`() {
mutableStateFlow.update {
it.copy(viewState = VerificationCodeState.ViewState.Error(message = "".asText()))
}
composeTestRule
.onNodeWithText("Try again")
.performClick()
verify { viewModel.trySendAction(VerificationCodeAction.RefreshClick) }
}
@Test
fun `error text and retry should be displayed according to state`() {
val message = "error_message"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update { it.copy(viewState = VerificationCodeState.ViewState.NoItems) }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update {
it.copy(viewState = VerificationCodeState.ViewState.Error(message.asText()))
}
composeTestRule
.onNodeWithText(message)
.assertIsDisplayed()
}
@Test
fun `Items text should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(1),
createDisplayItem(2),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
}
@Test
fun `Items text count should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, "1")
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
createDisplayItem(number = 2),
createDisplayItem(number = 3),
createDisplayItem(number = 4),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, "4")
}
@Test
fun `displayItems should be displayed according to state`() {
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "1")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Label 1")
.assertIsDisplayed()
}
@Test
fun `clicking on a display item should send ItemClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "Label 1")
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VerificationCodeAction.ItemClick("1"))
}
}
@Test
fun `clicking on copy button should send CopyClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithContentDescription(label = "Copy")
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VerificationCodeAction.CopyClick("123456"))
}
}
@Test
fun `on overflow item click should display menu`() {
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onAllNodesWithText(text = "Sync")
.filterToOne(hasAnyAncestor(isPopup()))
.isDisplayed()
composeTestRule
.onAllNodesWithText(text = "Lock")
.filterToOne(hasAnyAncestor(isPopup()))
.isDisplayed()
}
@Test
fun `on sync click should send SyncClick`() {
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onAllNodesWithText(text = "Sync")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
verify {
viewModel.trySendAction(VerificationCodeAction.SyncClick)
}
}
@Test
fun `on lock click should send LockClick`() {
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onAllNodesWithText(text = "Lock")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
verify {
viewModel.trySendAction(VerificationCodeAction.LockClick)
}
}
@Test
fun `loading dialog should be displayed according to state`() {
val loadingMessage = "syncing"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(loadingMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = VerificationCodeState.DialogState.Loading(
message = loadingMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(loadingMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
}
private fun createDisplayItem(number: Int): VerificationCodeDisplayItem =
VerificationCodeDisplayItem(
id = number.toString(),
authCode = "123456",
label = "Label $number",
supportingLabel = "Supporting Label $number",
periodSeconds = 30,
timeLeftSeconds = 15,
)
private val DEFAULT_STATE = VerificationCodeState(
viewState = VerificationCodeState.ViewState.Loading,
vaultFilterType = VaultFilterType.AllVaults,
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isPullToRefreshSettingEnabled = false,
dialogState = null,
)

View file

@ -1,16 +1,83 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import androidx.lifecycle.SavedStateHandle
import android.net.Uri
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.baseIconUrl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.toDisplayItem
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class VerificationCodeViewModelTest : BaseViewModelTest() {
private val clipboardManager: BitwardenClipboardManager = mockk()
private val mutableVaultDataStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { vaultFilterType } returns VaultFilterType.AllVaults
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync() } just runs
}
private val environmentRepository: EnvironmentRepository = mockk {
every { environment } returns Environment.Us
every { environmentStateFlow } returns mockk()
}
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
every { isIconLoadingDisabled } returns false
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
}
private val initialState = createVerificationCodeState()
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
initialState, awaitItem(),
)
}
}
@Test
fun `on BackClick should emit onNavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VerificationCodeAction.BackClick)
@ -18,6 +85,19 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `onCopyClick should call setText on the ClipboardManager`() {
val authCode = "123456"
val viewModel = createViewModel()
every { clipboardManager.setText(text = authCode) } just runs
viewModel.trySendAction(VerificationCodeAction.CopyClick(authCode))
verify(exactly = 1) {
clipboardManager.setText(text = authCode)
}
}
@Test
fun `on ItemClick should emit ItemClick`() = runTest {
val viewModel = createViewModel()
@ -29,13 +109,484 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel(
state: VerificationCodeState? = DEFAULT_STATE,
): VerificationCodeViewModel = VerificationCodeViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
}
@Test
fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VerificationCodeAction.SearchIconClick)
assertEquals(VerificationCodeEvent.NavigateToVaultSearchScreen, awaitItem())
}
}
private val DEFAULT_STATE: VerificationCodeState = VerificationCodeState(
VerificationCodeState.ViewState.Empty,
)
@Test
fun `LockClick should call lockVaultForCurrentUser`() {
every { vaultRepository.lockVaultForCurrentUser() } just runs
val viewModel = createViewModel()
viewModel.trySendAction(VerificationCodeAction.LockClick)
verify(exactly = 1) {
vaultRepository.lockVaultForCurrentUser()
}
}
@Test
fun `SyncClick should display the loading dialog and call sync`() {
val viewModel = createViewModel()
viewModel.trySendAction(VerificationCodeAction.SyncClick)
assertEquals(
initialState.copy(
dialogState = VerificationCodeState.DialogState.Loading(
message = R.string.syncing.asText(),
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
vaultRepository.sync()
}
}
@Test
fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VerificationCodeAction.ItemClick(id = "mock"))
assertEquals(VerificationCodeEvent.NavigateToVaultItem(id = "mock"), awaitItem())
}
}
@Test
fun `RefreshClick should sync`() = runTest {
val viewModel = createViewModel()
viewModel.actionChannel.trySend(VerificationCodeAction.RefreshClick)
verify { vaultRepository.sync() }
}
@Test
fun `vaultDataStateFlow Pending with data should update state to Content`() = runTest {
setupMockUri()
mutableVaultDataStateFlow.tryEmit(
value = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
),
)
val viewModel = createViewModel()
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
)
.toDisplayItem(
baseIconUrl = initialState.baseIconUrl,
isIconLoadingDisabled = initialState.isIconLoadingDisabled,
),
),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Pending with empty data should call NavigateBack to go to the vault screen`() =
runTest {
val dataState = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
}
}
@Test
fun `vaultDataStateFlow Pending with trash data should call NavigateBack event`() = runTest {
val dataState = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
}
}
@Test
fun `vaultDataStateFlow Error without data should update state to Error`() = runTest {
val dataState = DataState.Error<VaultData>(
error = IllegalStateException(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error with data should update state to Content`() = runTest {
setupMockUri()
val dataState = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
)
.toDisplayItem(
baseIconUrl = initialState.baseIconUrl,
isIconLoadingDisabled = initialState.isIconLoadingDisabled,
),
),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with empty data should call NavigateBack to go the vault screen`() =
runTest {
val dataState = DataState.Error(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
error = IllegalStateException(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Error with trash data should call NavigateBack to go to the vault screen`() =
runTest {
val dataState = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
error = IllegalStateException(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Test
fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest {
val dataState = DataState.NoNetwork<VaultData>()
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest {
setupMockUri()
val dataState = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Content(
verificationCodeDisplayItems = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
)
.toDisplayItem(
baseIconUrl = initialState.baseIconUrl,
isIconLoadingDisabled = initialState.isIconLoadingDisabled,
),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork with trash data should call NavigateBack`() = runTest {
val dataState = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Loaded with empty items should update call NavigateBack to go the vault screen`() =
runTest {
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Loaded with trash items should call NavigateBack to go to the vault screen`() =
runTest {
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.NavigateBack, awaitItem())
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
}
@Test
fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() =
runTest {
setupMockUri()
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
),
),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableVaultDataStateFlow.tryEmit(value = dataState)
assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem())
}
assertEquals(
createVerificationCodeState(
viewState = VerificationCodeState.ViewState.Content(
listOf(
createMockCipherView(
number = 1,
isDeleted = false,
)
.toDisplayItem(
baseIconUrl = initialState.baseIconUrl,
isIconLoadingDisabled = initialState.isIconLoadingDisabled,
),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loading should update state to Loading`() = runTest {
mutableVaultDataStateFlow.tryEmit(value = DataState.Loading)
val viewModel = createViewModel()
assertEquals(
createVerificationCodeState(viewState = VerificationCodeState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `icon loading state updates should update isIconLoadingDisabled`() = runTest {
val viewModel = createViewModel()
assertFalse(viewModel.stateFlow.value.isIconLoadingDisabled)
mutableIsIconLoadingDisabledFlow.value = true
assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled)
}
@Test
fun `RefreshPull should call vault repository sync`() {
val viewModel = createViewModel()
viewModel.trySendAction(VerificationCodeAction.RefreshPull)
verify(exactly = 1) {
vaultRepository.sync()
}
}
@Test
fun `PullToRefreshEnableReceive should update isPullToRefreshEnabled`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
VerificationCodeAction.Internal.PullToRefreshEnableReceive(
isPullToRefreshEnabled = true,
),
)
assertEquals(
initialState.copy(isPullToRefreshSettingEnabled = true),
viewModel.stateFlow.value,
)
}
private fun setupMockUri() {
mockkStatic(Uri::class)
val uriMock = mockk<Uri>()
every { Uri.parse(any()) } returns uriMock
every { uriMock.host } returns "www.mockuri.com"
}
private fun createViewModel(): VerificationCodeViewModel =
VerificationCodeViewModel(
clipboardManager = clipboardManager,
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
)
@Suppress("MaxLineLength")
private fun createVerificationCodeState(
viewState: VerificationCodeState.ViewState = VerificationCodeState.ViewState.Loading,
): VerificationCodeState =
VerificationCodeState(
viewState = viewState,
vaultFilterType = vaultRepository.vaultFilterType,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
dialogState = null,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
)
}