mirror of
https://github.com/bitwarden/android.git
synced 2024-11-27 12:00:19 +03:00
Adding the UI and ViewModel (#700)
This commit is contained in:
parent
cd1d326d45
commit
ffba00bf83
7 changed files with 1705 additions and 51 deletions
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue