BIT-931: Fill out about UI (#178)

This commit is contained in:
David Perez 2023-10-30 15:47:54 -05:00 committed by Álison Fernandes
parent 751b7ab2a8
commit 1bd09e42b3
9 changed files with 806 additions and 11 deletions

View file

@ -57,6 +57,7 @@ android {
jvmTarget = libs.versions.jvmTarget.get()
}
buildFeatures {
buildConfig = true
compose = true
}
composeOptions {

View file

@ -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)

View file

@ -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>) : 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].
*/

View file

@ -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 = { },
)
}
}

View file

@ -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 = { },
)
}
}

View file

@ -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<Unit, AboutEvent, AboutAction>(
initialState = Unit,
class AboutViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<AboutState, AboutEvent, AboutAction>(
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()
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#1B1B1F"
android:pathData="M17.366,2.08L20.811,2.206L20.812,2.206C21.106,2.218 21.384,2.346 21.586,2.562C21.788,2.777 21.897,3.063 21.889,3.357L21.798,6.76C21.784,6.924 21.708,7.078 21.585,7.187C21.461,7.296 21.3,7.354 21.134,7.347C20.968,7.341 20.811,7.271 20.694,7.152C20.577,7.034 20.51,6.875 20.507,6.71L20.563,4.589C20.559,4.57 20.55,4.552 20.537,4.537C20.524,4.523 20.508,4.511 20.49,4.504C20.471,4.497 20.452,4.495 20.433,4.497C20.413,4.5 20.395,4.507 20.379,4.518L13.545,11.325C13.422,11.437 13.261,11.497 13.094,11.493C12.927,11.489 12.768,11.42 12.649,11.302C12.531,11.184 12.462,11.025 12.457,10.857C12.452,10.69 12.512,10.529 12.624,10.406L19.411,3.653C19.511,3.454 19.257,3.44 19.257,3.44L17.329,3.37C17.164,3.355 17.01,3.277 16.899,3.153C16.789,3.028 16.73,2.867 16.734,2.701C16.739,2.535 16.808,2.378 16.925,2.263C17.042,2.147 17.201,2.082 17.366,2.08Z" />
<path
android:fillColor="#1B1B1F"
android:pathData="M12,3.25H5.125C4.089,3.25 3.25,4.089 3.25,5.125V18.875C3.25,19.91 4.089,20.75 5.125,20.75H18.875C19.91,20.75 20.75,19.91 20.75,18.875V12C20.75,11.655 20.47,11.375 20.125,11.375C19.78,11.375 19.5,11.655 19.5,12V18.875C19.5,19.22 19.22,19.5 18.875,19.5H5.125C4.78,19.5 4.5,19.22 4.5,18.875V5.125C4.5,4.78 4.78,4.5 5.125,4.5H12C12.345,4.5 12.625,4.22 12.625,3.875C12.625,3.53 12.345,3.25 12,3.25Z" />
</vector>

View file

@ -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<ClipboardManager> {
every { setText(any()) } just Runs
}
val viewModel = mockk<AboutViewModel> {
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<AboutViewModel> {
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<IntentHandler> {
every { launchUri(any()) } just Runs
}
val viewModel = mockk<AboutViewModel> {
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<IntentHandler> {
every { launchUri(any()) } just Runs
}
val viewModel = mockk<AboutViewModel> {
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<IntentHandler> {
every { launchUri(any()) } just Runs
}
val viewModel = mockk<AboutViewModel> {
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<AboutViewModel>(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<AboutViewModel> {
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()
}
}

View file

@ -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)
}
}