mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 19:28:44 +03:00
BIT-931: Fill out about UI (#178)
This commit is contained in:
parent
751b7ab2a8
commit
1bd09e42b3
9 changed files with 806 additions and 11 deletions
|
@ -57,6 +57,7 @@ android {
|
|||
jvmTarget = libs.versions.jvmTarget.get()
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
12
app/src/main/res/drawable/ic_external_link.xml
Normal file
12
app/src/main/res/drawable/ic_external_link.xml
Normal 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>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue