diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt new file mode 100644 index 000000000..e1c82294c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt index dfa056169..305db5db3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt @@ -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, + 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), + ) } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt index 7a5fb2e57..f82f2c50b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt @@ -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( - 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) { + 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) { + 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) { + updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = false) + } + + private fun vaultLoadedReceive(vaultData: DataState.Loaded) { + 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) { + 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, + ) : 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, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt new file mode 100644 index 000000000..393fcbc72 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt @@ -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)) }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt new file mode 100644 index 000000000..3f05ac42a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt @@ -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.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.toDisplayItemList( + baseIconUrl: String, + isIconLoadingDisabled: Boolean, +): List = + 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, + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt new file mode 100644 index 000000000..b9a624940 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt index efadbfa5b..d238b166f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt @@ -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.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( + 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() + + 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() + 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, + ) +}