diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf81fb59da..244dbda346 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,7 @@ android { jvmTarget = libs.versions.jvmTarget.get() } buildFeatures { + buildConfig = true compose = true } composeOptions { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt index c9cc32acf0..f4b651e020 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.platform.util +import androidx.compose.ui.text.AnnotatedString + /** * Returns the original [String] only if: * @@ -10,3 +12,8 @@ package com.x8bit.bitwarden.data.platform.util */ fun String?.orNullIfBlank(): String? = this?.takeUnless { it.isBlank() } + +/** + * Returns the [String] as an [AnnotatedString]. + */ +fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(text = this) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt index 7d1a64802c..0d64537f17 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt @@ -42,6 +42,16 @@ private data class ResText(@StringRes private val id: Int) : Text { override fun invoke(res: Resources): CharSequence = res.getText(id) } +/** + * Implementation of [Text] backed by an array of [Text]s. This makes it easy to concatenate texts. + */ +@Parcelize +private data class TextConcatenation(private val args: List) : Text { + override fun invoke( + res: Resources, + ): CharSequence = args.joinToString(separator = "") { it.invoke(res) } +} + /** * Implementation of [Text] that formats a string resource with arguments. */ @@ -93,6 +103,11 @@ private data class StringText(private val string: String) : Text { */ fun String.asText(): Text = StringText(this) +/** + * Concatenates multiple [Text]s into a singular [Text]. + */ +fun Text.concat(vararg args: Text): Text = TextConcatenation(listOf(this, *args)) + /** * Convert a resource Id to [Text]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenExternalLinkRow.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenExternalLinkRow.kt new file mode 100644 index 0000000000..7b7e4aea0a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenExternalLinkRow.kt @@ -0,0 +1,96 @@ +package com.x8bit.bitwarden.ui.platform.components + +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.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * Represents a row of text that can be clicked on and contains an external link. + * + * @param text The label for the row as a [Text]. + * @param onClick The callback when the row is clicked. + * @param modifier The modifier to be applied to the layout. + */ +@Composable +fun BitwardenExternalLinkRow( + text: Text, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + contentDescription = text.toString(resources) + }, + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = text(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + painter = painterResource(id = R.drawable.ic_external_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +@Preview +@Composable +private fun BitwardenExternalLinkRow_preview() { + BitwardenTheme { + BitwardenExternalLinkRow( + text = "Linked Text".asText(), + onClick = { }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt index bcf97d7ae5..82fd1337b7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt @@ -1,39 +1,97 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about +import android.widget.Toast import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState 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.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.util.toAnnotatedString import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Displays the about screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreen( onNavigateBack: () -> Unit, viewModel: AboutViewModel = hiltViewModel(), + clipboardManager: ClipboardManager = LocalClipboardManager.current, + intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources EventsEffect(viewModel = viewModel) { event -> when (event) { + is AboutEvent.CopyToClipboard -> { + clipboardManager.setText(event.text.toString(resources).toAnnotatedString()) + } + AboutEvent.NavigateBack -> onNavigateBack.invoke() + + AboutEvent.NavigateToHelpCenter -> { + intentHandler.launchUri("https://bitwarden.com/help".toUri()) + } + + AboutEvent.NavigateToLearnAboutOrganizations -> { + intentHandler.launchUri("https://bitwarden.com/help/about-organizations".toUri()) + } + + AboutEvent.NavigateToWebVault -> { + intentHandler.launchUri("https://vault.bitwarden.com".toUri()) + } + + is AboutEvent.ShowToast -> { + Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() + } } } @@ -54,14 +112,153 @@ fun AboutScreen( ) }, ) { innerPadding -> - Column( - Modifier + ContentColum( + state = state, + modifier = Modifier .padding(innerPadding) - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surface) - .verticalScroll(rememberScrollState()), + .fillMaxSize(), + onHelpCenterClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.HelpCenterClick) } + }, + onLearnAboutOrgsClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.LearnAboutOrganizationsClick) } + }, + onRateTheAppClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.RateAppClick) } + }, + onSubmitCrashLogsCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AboutAction.SubmitCrashLogsClick(it)) } + }, + onVersionClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.VersionClick) } + }, + onWebVaultClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.WebVaultClick) } + }, + ) + } +} + +@Composable +private fun ContentColum( + state: AboutState, + onHelpCenterClick: () -> Unit, + onLearnAboutOrgsClick: () -> Unit, + onRateTheAppClick: () -> Unit, + onSubmitCrashLogsCheckedChange: (Boolean) -> Unit, + onVersionClick: () -> Unit, + onWebVaultClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(color = MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenWideSwitch( + label = stringResource(id = R.string.submit_crash_logs), + isChecked = state.isSubmitCrashLogsEnabled, + onCheckedChange = onSubmitCrashLogsCheckedChange, + modifier = Modifier + .defaultMinSize(56.dp) + .padding(horizontal = 16.dp), + contentDescription = stringResource(id = R.string.submit_crash_logs), + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenExternalLinkRow( + text = R.string.bitwarden_help_center.asText(), + onClick = onHelpCenterClick, + ) + BitwardenExternalLinkRow( + text = R.string.web_vault.asText(), + onClick = onWebVaultClick, + ) + BitwardenExternalLinkRow( + text = R.string.learn_org.asText(), + onClick = onLearnAboutOrgsClick, + ) + BitwardenExternalLinkRow( + text = R.string.rate_the_app.asText(), + onClick = onRateTheAppClick, + ) + CopyRow( + text = state.version, + onClick = onVersionClick, + ) + Box( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, ) { - // TODO: BIT-931 Display About UI + Text( + modifier = Modifier.padding(end = 16.dp), + text = "© Bitwarden Inc. 2015-2023", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) } } } + +@Composable +private fun CopyRow( + text: Text, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + contentDescription = text.toString(resources) + }, + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = text(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +@Preview +@Composable +private fun CopyRow_preview() { + BitwardenTheme { + CopyRow( + text = "Copyable Text".asText(), + onClick = { }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt index 49d6b04ae6..325df428b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt @@ -1,29 +1,135 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.BuildConfig +import com.x8bit.bitwarden.R 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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject +private const val KEY_STATE = "state" + /** * View model for the about screen. */ @HiltViewModel -class AboutViewModel @Inject constructor() : BaseViewModel( - initialState = Unit, +class AboutViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: INITIAL_STATE, ) { + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + override fun handleAction(action: AboutAction): Unit = when (action) { - AboutAction.BackClick -> sendEvent(AboutEvent.NavigateBack) + AboutAction.BackClick -> handleBackClick() + AboutAction.HelpCenterClick -> handleHelpCenterClick() + AboutAction.LearnAboutOrganizationsClick -> handleLearnAboutOrganizationsClick() + AboutAction.RateAppClick -> handleRateAppClick() + is AboutAction.SubmitCrashLogsClick -> handleSubmitCrashLogsClick(action) + AboutAction.VersionClick -> handleVersionClick() + AboutAction.WebVaultClick -> handleWebVaultClick() + } + + private fun handleBackClick() { + sendEvent(AboutEvent.NavigateBack) + } + + private fun handleHelpCenterClick() { + sendEvent(AboutEvent.NavigateToHelpCenter) + } + + private fun handleLearnAboutOrganizationsClick() { + sendEvent(AboutEvent.NavigateToLearnAboutOrganizations) + } + + private fun handleRateAppClick() { + // TODO: BIT-748 Launch the rate your app UI. + sendEvent(AboutEvent.ShowToast(text = "Navigate to rate the app.".asText())) + } + + private fun handleSubmitCrashLogsClick(action: AboutAction.SubmitCrashLogsClick) { + mutableStateFlow.update { currentState -> + currentState.copy(isSubmitCrashLogsEnabled = action.enabled) + } + } + + private fun handleVersionClick() { + sendEvent(AboutEvent.CopyToClipboard(text = stateFlow.value.version)) + } + + private fun handleWebVaultClick() { + sendEvent(AboutEvent.NavigateToWebVault) + } + + companion object { + private val INITIAL_STATE: AboutState = AboutState( + version = R.string.version + .asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + isSubmitCrashLogsEnabled = false, + ) } } +/** + * Represents the state of the about screen. + */ +@Parcelize +data class AboutState( + val version: Text, + val isSubmitCrashLogsEnabled: Boolean, +) : Parcelable + /** * Models events for the about screen. */ sealed class AboutEvent { + /** + * Copy the given [text] to the clipboard. + */ + data class CopyToClipboard( + val text: Text, + ) : AboutEvent() + /** * Navigate back. */ data object NavigateBack : AboutEvent() + + /** + * Navigates to the help center. + */ + data object NavigateToHelpCenter : AboutEvent() + + /** + * Navigates to learn about organizations. + */ + data object NavigateToLearnAboutOrganizations : AboutEvent() + + /** + * Navigates to the web vault. + */ + data object NavigateToWebVault : AboutEvent() + + /** + * Displays a toast with the given [Text]. + */ + data class ShowToast( + val text: Text, + ) : AboutEvent() } /** @@ -34,4 +140,36 @@ sealed class AboutAction { * User clicked back button. */ data object BackClick : AboutAction() + + /** + * User clicked the helper center row. + */ + data object HelpCenterClick : AboutAction() + + /** + * User clicked the learn about organizations row. + */ + data object LearnAboutOrganizationsClick : AboutAction() + + /** + * User clicked the rate the app row. + */ + data object RateAppClick : AboutAction() + + /** + * User clicked the submit crash logs toggle. + */ + data class SubmitCrashLogsClick( + val enabled: Boolean, + ) : AboutAction() + + /** + * User clicked the version row. + */ + data object VersionClick : AboutAction() + + /** + * User clicked the web vault row. + */ + data object WebVaultClick : AboutAction() } diff --git a/app/src/main/res/drawable/ic_external_link.xml b/app/src/main/res/drawable/ic_external_link.xml new file mode 100644 index 0000000000..63c65dfe0c --- /dev/null +++ b/app/src/main/res/drawable/ic_external_link.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt index cd9dd841bf..73a97e865a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt @@ -1,21 +1,42 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.core.net.toUri +import com.x8bit.bitwarden.data.platform.util.toAnnotatedString import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.Runs import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertTrue import org.junit.Test class AboutScreenTest : BaseComposeTest() { + private val mutableStateFlow = MutableStateFlow( + AboutState( + version = "Version: 1.0.0 (1)".asText(), + isSubmitCrashLogsEnabled = false, + ), + ) @Test fun `on back click should send BackClick`() { val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow every { eventFlow } returns emptyFlow() every { trySendAction(AboutAction.BackClick) } returns Unit } @@ -30,9 +51,89 @@ class AboutScreenTest : BaseComposeTest() { } @Test - fun `on NavigateAbout should call onNavigateToAbout`() { + fun `on bitwarden help center click should send HelpCenterClick`() { + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.HelpCenterClick) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Bitwarden Help Center").performClick() + verify { + viewModel.trySendAction(AboutAction.HelpCenterClick) + } + } + + @Test + fun `on bitwarden web vault click should send WebVaultClick`() { + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.WebVaultClick) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Bitwarden web vault").performClick() + verify { + viewModel.trySendAction(AboutAction.WebVaultClick) + } + } + + @Test + fun `on CopyToClipboard should call setText on ClipboardManager`() { + val text = "copy text" + val clipboardManager = mockk { + every { setText(any()) } just Runs + } + val viewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns flowOf(AboutEvent.CopyToClipboard(text.asText())) + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + clipboardManager = clipboardManager, + ) + } + verify { + clipboardManager.setText(text.toAnnotatedString()) + } + } + + @Test + fun `on learn about organizations click should send LearnAboutOrganizationsClick`() { + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.LearnAboutOrganizationsClick) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Learn about organizations").performClick() + verify { + viewModel.trySendAction(AboutAction.LearnAboutOrganizationsClick) + } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { var haveCalledNavigateBack = false val viewModel = mockk { + every { stateFlow } returns mutableStateFlow every { eventFlow } returns flowOf(AboutEvent.NavigateBack) } composeTestRule.setContent { @@ -43,4 +144,160 @@ class AboutScreenTest : BaseComposeTest() { } assertTrue(haveCalledNavigateBack) } + + @Test + fun `on NavigateToHelpCenter should call launchUri on IntentHandler`() { + val intentHandler = mockk { + every { launchUri(any()) } just Runs + } + val viewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns flowOf(AboutEvent.NavigateToHelpCenter) + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + intentHandler = intentHandler, + ) + } + verify { + intentHandler.launchUri("https://bitwarden.com/help".toUri()) + } + } + + @Test + fun `on NavigateToLearnAboutOrganizations should call launchUri on IntentHandler`() { + val intentHandler = mockk { + every { launchUri(any()) } just Runs + } + val viewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns flowOf(AboutEvent.NavigateToLearnAboutOrganizations) + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + intentHandler = intentHandler, + ) + } + verify { + intentHandler.launchUri("https://bitwarden.com/help/about-organizations".toUri()) + } + } + + @Test + fun `on NavigateToWebVault should call launchUri on IntentHandler`() { + val intentHandler = mockk { + every { launchUri(any()) } just Runs + } + val viewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns flowOf(AboutEvent.NavigateToWebVault) + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + intentHandler = intentHandler, + ) + } + verify { + intentHandler.launchUri("https://vault.bitwarden.com".toUri()) + } + } + + @Test + fun `on rate the app click should send RateAppClick`() { + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.RateAppClick) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Rate the app").performClick() + verify { + viewModel.trySendAction(AboutAction.RateAppClick) + } + } + + @Test + fun `on submit crash logs toggle should send SubmitCrashLogsClick`() { + val enabled = true + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.SubmitCrashLogsClick(enabled)) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Submit crash logs").performClick() + verify { + viewModel.trySendAction(AboutAction.SubmitCrashLogsClick(enabled)) + } + } + + fun `on submit crash logs should be toggled on or off according to the state`() { + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns mutableStateFlow + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Submit crash logs").assertIsOff() + mutableStateFlow.update { it.copy(isSubmitCrashLogsEnabled = true) } + composeTestRule.onNodeWithText("Submit crash logs").assertIsOn() + } + + @Test + fun `on version info click should send VersionClick`() { + val viewModel: AboutViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(AboutAction.VersionClick) } returns Unit + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Version: 1.0.0 (1)").performClick() + verify { + viewModel.trySendAction(AboutAction.VersionClick) + } + } + + @Test + fun `version should update according to the state`() = runTest { + val viewModel = mockk { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns mutableStateFlow + } + composeTestRule.setContent { + AboutScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Version: 1.0.0 (1)").assertIsDisplayed() + + mutableStateFlow.update { it.copy(version = "Version: 1.1.0 (2)".asText()) } + + composeTestRule.onNodeWithText("Version: 1.1.0 (2)").assertIsDisplayed() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt index 2ea443249c..f208e821d2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt @@ -1,19 +1,91 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import kotlinx.coroutines.test.runTest 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 AboutViewModelTest : BaseViewModelTest() { + private val initialState = createAboutState() + private val initialSavedStateHandle = createSavedStateHandleWithState(initialState) + @Test fun `on BackClick should emit NavigateBack`() = runTest { - val viewModel = AboutViewModel() + val viewModel = AboutViewModel(initialSavedStateHandle) viewModel.eventFlow.test { viewModel.trySendAction(AboutAction.BackClick) assertEquals(AboutEvent.NavigateBack, awaitItem()) } } + + @Test + fun `on HelpCenterClick should emit NavigateToHelpCenter`() = runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.HelpCenterClick) + assertEquals(AboutEvent.NavigateToHelpCenter, awaitItem()) + } + } + + @Test + fun `on LearnAboutOrganizationsClick should emit NavigateToLearnAboutOrganizations`() = + runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.LearnAboutOrganizationsClick) + assertEquals(AboutEvent.NavigateToLearnAboutOrganizations, awaitItem()) + } + } + + @Test + fun `on RateAppClick should emit ShowToast`() = runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.RateAppClick) + assertEquals(AboutEvent.ShowToast("Navigate to rate the app.".asText()), awaitItem()) + } + } + + @Test + fun `on SubmitCrashLogsClick should update isSubmitCrashLogsEnabled to true`() = runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + assertFalse(viewModel.stateFlow.value.isSubmitCrashLogsEnabled) + viewModel.trySendAction(AboutAction.SubmitCrashLogsClick(true)) + assertTrue(viewModel.stateFlow.value.isSubmitCrashLogsEnabled) + } + + @Test + fun `on VersionClick should emit CopyToClipboard`() = runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.VersionClick) + assertEquals(AboutEvent.CopyToClipboard("0".asText()), awaitItem()) + } + } + + @Test + fun `on WebVaultClick should emit NavigateToWebVault`() = runTest { + val viewModel = AboutViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.WebVaultClick) + assertEquals(AboutEvent.NavigateToWebVault, awaitItem()) + } + } + + private fun createAboutState(): AboutState = + AboutState( + version = "0".asText(), + isSubmitCrashLogsEnabled = false, + ) + + private fun createSavedStateHandleWithState(state: AboutState) = + SavedStateHandle().apply { + set("state", state) + } }