Allow users to save items to local storage (#18)

This commit is contained in:
Patrick Honkonen
2024-04-05 16:12:25 -04:00
committed by GitHub
parent 38a92042de
commit cd992a6994
18 changed files with 607 additions and 71 deletions

View File

@@ -29,14 +29,17 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.authenticator.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.authenticator.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.authenticator.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.authenticator.ui.platform.components.icon.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.authenticator.ui.platform.components.indicator.BitwardenCircularCountdownIndicator
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
import kotlinx.collections.immutable.toImmutableList
private const val AUTH_CODE_SPACING_INTERVAL = 3
@@ -104,7 +107,8 @@ fun ItemScreen(
.fillMaxSize()
.padding(innerPadding),
viewState = viewState,
onCopyTotpClick = { }
onCopyTotpClick = { },
onTypeOptionClicked = { }
)
is ItemState.ViewState.Error -> {
@@ -121,6 +125,7 @@ fun ItemContent(
modifier: Modifier = Modifier,
viewState: ItemState.ViewState.Content,
onCopyTotpClick: () -> Unit,
onTypeOptionClicked: (AuthenticatorItemType) -> Unit,
) {
LazyColumn(modifier = modifier) {
@@ -128,18 +133,42 @@ fun ItemContent(
BitwardenTextField(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.name),
value = viewState.itemData.name,
value = viewState.itemData.issuer(),
onValueChange = {},
readOnly = true,
singleLine = true,
)
}
item {
val possibleTypeOptions = AuthenticatorItemType.entries
val typeOptionsWithStrings = possibleTypeOptions.associateWith { it.name }
Spacer(modifier = Modifier.height(8.dp))
BitwardenMultiSelectButton(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.semantics { testTag = "ItemTypePicker" },
label = stringResource(id = R.string.type),
options = typeOptionsWithStrings.values.toImmutableList(),
selectedOption = viewState.itemData.type.name,
onOptionSelected = { selectedOption ->
val selectedOptionName = typeOptionsWithStrings
.entries
.first { it.value == selectedOption }
.key
onTypeOptionClicked(selectedOptionName)
},
isEnabled = false,
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.verification_code_totp),
value = viewState.itemData.totpCodeItemData?.verificationCode.orEmpty()
value = viewState.itemData.verificationCode()
.chunked(AUTH_CODE_SPACING_INTERVAL)
.joinToString(" "),
onValueChange = { },
@@ -147,9 +176,9 @@ fun ItemContent(
singleLine = true,
actions = {
BitwardenCircularCountdownIndicator(
timeLeftSeconds = viewState.itemData.totpCodeItemData?.timeLeftSeconds ?: 0,
periodSeconds = viewState.itemData.totpCodeItemData?.periodSeconds ?: 0,
alertThresholdSeconds = viewState.itemData.alertThresholdSeconds
timeLeftSeconds = viewState.itemData.timeLeftSeconds,
periodSeconds = viewState.itemData.periodSeconds,
alertThresholdSeconds = viewState.itemData.alertThresholdSeconds,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
@@ -164,5 +193,37 @@ fun ItemContent(
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
label = stringResource(id = R.string.totp_code),
value = viewState.itemData.totpCode(),
onValueChange = { },
readOnly = true,
singleLine = true,
)
}
viewState.itemData.username?.let { username ->
item {
if (username.invoke().isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
label = stringResource(id = R.string.username),
value = username(),
onValueChange = {},
readOnly = true,
singleLine = true,
)
}
}
}
}
}

View File

@@ -3,16 +3,17 @@ package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.authenticator.data.platform.repository.model.DataState
import com.x8bit.bitwarden.authenticator.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.model.ItemData
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.concat
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
@@ -47,24 +48,23 @@ class ItemViewModel @Inject constructor(
) { itemState, authCodeState, alertThresholdSeconds ->
ItemAction.Internal.ItemDataReceive(
item = itemState.data,
itemDataState = combineDataStates(
itemState,
authCodeState,
) { item, authCode ->
val totpData = authCode?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
totpCode = it.totpCode,
verificationCode = it.code
)
}
ItemData(
name = item?.username.orEmpty(),
item ?: return@combineDataStates null
authCode ?: return@combineDataStates null
TotpCodeItemData(
type = item.type,
username = item.username?.asText(),
issuer = item.issuer.orEmpty().asText(),
periodSeconds = authCode.periodSeconds,
timeLeftSeconds = authCode.timeLeftSeconds,
totpCode = authCode.totpCode.asText(),
verificationCode = authCode.code.asText(),
alertThresholdSeconds = alertThresholdSeconds,
totpCodeItemData = totpData
)
}
)
@@ -126,23 +126,64 @@ class ItemViewModel @Inject constructor(
}
private fun handleItemDataReceive(action: ItemAction.Internal.ItemDataReceive) {
val totpItemData = action.itemDataState.data?.totpCodeItemData ?: return
val alertThreshold = action.itemDataState.data?.alertThresholdSeconds ?: 0
mutableStateFlow.update {
it.copy(
itemId = action.item?.id.orEmpty(),
viewState = ItemState.ViewState.Content(
itemData = ItemData(
name = action.item?.username.orEmpty(),
alertThresholdSeconds = alertThreshold,
totpCodeItemData = totpItemData,
when (val itemState = action.itemDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = ItemState.ViewState.Error(
message = R.string.generic_error_message.asText()
),
)
)
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = itemState
.data
?.toViewState()
?: ItemState.ViewState.Error(
message = R.string.generic_error_message.asText()
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update { it.copy(viewState = ItemState.ViewState.Loading) }
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = ItemState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = itemState
.data
?.toViewState()
?: ItemState.ViewState.Error(
message = R.string.generic_error_message.asText()
),
)
}
}
}
}
}
private fun TotpCodeItemData.toViewState() = ItemState.ViewState.Content(this)
/**
* Represents the state for displaying an item in the authenticator.
*
@@ -181,7 +222,7 @@ data class ItemState(
*/
@Parcelize
data class Content(
val itemData: ItemData,
val itemData: TotpCodeItemData,
) : ViewState()
}
@@ -195,7 +236,7 @@ data class ItemState(
*/
@Parcelize
data class Generic(
val message: String,
val message: Text,
) : DialogState()
/**
@@ -283,8 +324,7 @@ sealed class ItemAction {
* Indicates that the item data has been received.
*/
data class ItemDataReceive(
val item: AuthenticatorItemEntity?,
val itemDataState: DataState<ItemData?>,
val itemDataState: DataState<TotpCodeItemData?>,
) : Internal()
/**

View File

@@ -1,19 +0,0 @@
package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents the item data displayed to users.
*
* @property name Name of the account associated to the item.
* @property alertThresholdSeconds Threshold, in seconds, at which an Item is considered near
* expiration.
* @property totpCodeItemData TOTP data for the account.
*/
@Parcelize
data class ItemData(
val name: String,
val alertThresholdSeconds: Int,
val totpCodeItemData: TotpCodeItemData?,
) : Parcelable

View File

@@ -1,6 +1,9 @@
package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.model
import android.os.Parcelable
import com.x8bit.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.ItemListingAction
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import kotlinx.parcelize.Parcelize
/**
@@ -10,11 +13,18 @@ import kotlinx.parcelize.Parcelize
* @property timeLeftSeconds The time left for the verification timer.
* @property verificationCode The verification code for the item.
* @property totpCode The totp code for the item.
* @property issuer Name of the item provider.
* @property alertThresholdSeconds Threshold, in seconds, at which an Item is considered near
* expiration.
*/
@Parcelize
data class TotpCodeItemData(
val periodSeconds: Int,
val timeLeftSeconds: Int,
val verificationCode: String,
val totpCode: String,
val verificationCode: Text,
val totpCode: Text,
val type: AuthenticatorItemType,
val username: Text?,
val issuer: Text,
val alertThresholdSeconds: Int
) : Parcelable

View File

@@ -159,7 +159,7 @@ fun ItemListingScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp),
startIcon = it.startIcon,
label = it.label,
issuer = it.issuer,
supportingLabel = it.supportingLabel,
timeLeftSeconds = it.timeLeftSeconds,
periodSeconds = it.periodSeconds,

View File

@@ -375,6 +375,7 @@ sealed class ItemListingAction {
data class VerificationCodeDisplayItem(
val id: String,
val label: String,
val issuer: String?,
val supportingLabel: String?,
val timeLeftSeconds: Int,
val periodSeconds: Int,

View File

@@ -32,7 +32,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
* The verification code item displayed to the user.
*
* @param authCode The code for the item.
* @param label The label for the item.
* @param issuer The label for the item.
* @param periodSeconds The times span where the code is valid.
* @param timeLeftSeconds The seconds remaining until a new code is needed.
* @param startIcon The leading icon for the item.
@@ -45,7 +45,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
@Composable
fun VaultVerificationCodeItem(
authCode: String,
label: String,
issuer: String?,
periodSeconds: Int,
timeLeftSeconds: Int,
alertThresholdSeconds: Int,
@@ -80,13 +80,15 @@ fun VaultVerificationCodeItem(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.weight(1f),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
issuer?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
supportingLabel?.let {
Text(
@@ -131,7 +133,7 @@ private fun VerificationCodeItem_preview() {
AuthenticatorTheme {
VaultVerificationCodeItem(
startIcon = IconData.Local(R.drawable.ic_login_item),
label = "Sample Label",
issuer = "Sample Label",
supportingLabel = "Supporting Label",
authCode = "1234567890".chunked(3).joinToString(" "),
timeLeftSeconds = 15,

View File

@@ -19,6 +19,7 @@ fun VerificationCodeItem.toDisplayItem(alertThresholdSeconds: Int) =
VerificationCodeDisplayItem(
id = id,
label = label,
issuer = issuer,
supportingLabel = username,
timeLeftSeconds = timeLeftSeconds,
periodSeconds = periodSeconds,

View File

@@ -147,6 +147,22 @@ fun ManualCodeEntryScreen(
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.issuer),
value = state.issuer,
onValueChange = remember(viewModel) {
{
viewModel.trySendAction(
ManualCodeEntryAction.IssuerTextChange(it),
)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.add_totp),

View File

@@ -2,12 +2,13 @@ package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeent
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -23,18 +24,25 @@ class ManualCodeEntryViewModel @Inject constructor(
private val authenticatorRepository: AuthenticatorRepository,
) : BaseViewModel<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>(
initialState = savedStateHandle[KEY_STATE]
?: ManualCodeEntryState(code = ""),
?: ManualCodeEntryState(code = "", issuer = ""),
) {
override fun handleAction(action: ManualCodeEntryAction) {
when (action) {
is ManualCodeEntryAction.CloseClick -> handleCloseClick()
is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action)
is ManualCodeEntryAction.IssuerTextChange -> handleIssuerTextChange(action)
is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit()
is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick()
is ManualCodeEntryAction.SettingsClick -> handleSettingsClick()
}
}
private fun handleIssuerTextChange(action: ManualCodeEntryAction.IssuerTextChange) {
mutableStateFlow.update {
it.copy(issuer = action.issuer)
}
}
private fun handleCloseClick() {
sendEvent(ManualCodeEntryEvent.NavigateBack)
}
@@ -46,7 +54,9 @@ class ManualCodeEntryViewModel @Inject constructor(
}
private fun handleCodeSubmit() {
authenticatorRepository.emitTotpCodeResult(TotpCodeResult.Success(state.code))
viewModelScope.launch {
authenticatorRepository.createItem(state.code, state.issuer)
}
sendEvent(ManualCodeEntryEvent.NavigateBack)
}
@@ -65,6 +75,7 @@ class ManualCodeEntryViewModel @Inject constructor(
@Parcelize
data class ManualCodeEntryState(
val code: String,
val issuer: String,
) : Parcelable
/**
@@ -113,6 +124,11 @@ sealed class ManualCodeEntryAction {
*/
data class CodeTextChange(val code: String) : ManualCodeEntryAction()
/**
* The use has changed the issuer text.
*/
data class IssuerTextChange(val issuer: String) : ManualCodeEntryAction()
/**
* The text to switch to QR code scanning is clicked.
*/

View File

@@ -0,0 +1,101 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.dialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.authenticator.ui.platform.components.util.maxDialogHeight
/**
* Displays a dialog with a title and "Cancel" button.
*
* @param title Title to display.
* @param onDismissRequest Invoked when the user dismisses the dialog.
* @param selectionItems Lambda containing selection items to show to the user. See
* [BitwardenSelectionRow].
*/
@Suppress("LongMethod")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun BitwardenSelectionDialog(
title: String,
onDismissRequest: () -> Unit,
selectionItems: @Composable ColumnScope.() -> Unit = {},
) {
Dialog(
onDismissRequest = onDismissRequest,
) {
val configuration = LocalConfiguration.current
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.semantics { testTagsAsResourceId = true }
.requiredHeightIn(
max = configuration.maxDialogHeight,
)
// This background is necessary for the dialog to not be transparent.
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(28.dp),
),
horizontalAlignment = Alignment.End,
) {
Text(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
if (scrollState.canScrollBackward) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(scrollState),
content = selectionItems,
)
if (scrollState.canScrollForward) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
BitwardenTextButton(
modifier = Modifier.padding(24.dp),
label = stringResource(id = R.string.cancel),
onClick = onDismissRequest,
)
}
}
}

View File

@@ -0,0 +1,52 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.dialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
/**
* A clickable item that displays a radio button and text.
*
* @param text The text to display.
* @param onClick Invoked when either the radio button or text is clicked.
* @param isSelected Whether or not the radio button should be checked.
*/
@Composable
fun BitwardenSelectionRow(
text: Text,
onClick: () -> Unit,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
selected = isSelected
},
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
modifier = Modifier.padding(16.dp),
selected = isSelected,
onClick = null,
)
Text(
text = text(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
)
}
}

View File

@@ -0,0 +1,186 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.dropdown
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionRow
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.TooltipData
import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* A custom composable representing a multi-select button.
*
* This composable displays an [OutlinedTextField] with a dropdown icon as a trailing icon.
* When the field is clicked, a dropdown menu appears with a list of options to select from.
*
* @param label The descriptive text label for the [OutlinedTextField].
* @param options A list of strings representing the available options in the dialog.
* @param selectedOption The currently selected option that is displayed in the [OutlinedTextField]
* (or `null` if no option is selected).
* @param onOptionSelected A lambda that is invoked when an option
* is selected from the dropdown menu.
* @param isEnabled Whether or not the button is enabled.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
* @param supportingText A optional supporting text that will appear below the text field.
* @param tooltip A nullable [TooltipData], representing the tooltip icon.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenMultiSelectButton(
label: String,
options: ImmutableList<String>,
selectedOption: String?,
onOptionSelected: (String) -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
supportingText: String? = null,
tooltip: TooltipData? = null,
) {
var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
OutlinedTextField(
modifier = modifier
.clearAndSetSemantics {
role = Role.DropdownList
contentDescription = supportingText
?.let { "$selectedOption. $label. $it" }
?: "$selectedOption. $label"
customActions = listOfNotNull(
tooltip?.let {
CustomAccessibilityAction(
label = it.contentDescription,
action = {
it.onClick()
true
},
)
},
)
}
.fillMaxWidth()
.clickable(
indication = null,
enabled = isEnabled,
interactionSource = remember { MutableInteractionSource() },
) {
shouldShowDialog = !shouldShowDialog
},
textStyle = MaterialTheme.typography.bodyLarge,
readOnly = true,
label = {
Row {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
tooltip?.let {
Spacer(modifier = Modifier.width(3.dp))
IconButton(
onClick = it.onClick,
enabled = isEnabled,
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.size(16.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip_small),
contentDescription = it.contentDescription,
)
}
}
}
},
value = selectedOption.orEmpty(),
onValueChange = onOptionSelected,
enabled = shouldShowDialog,
trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_region_select_dropdown),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
supportingText = supportingText?.let {
{
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
)
}
},
)
if (shouldShowDialog) {
BitwardenSelectionDialog(
title = label,
onDismissRequest = { shouldShowDialog = false },
) {
options.forEach { optionString ->
BitwardenSelectionRow(
text = optionString.asText(),
isSelected = optionString == selectedOption,
onClick = {
shouldShowDialog = false
onOptionSelected(optionString)
},
)
}
}
}
}
@Preview
@Composable
private fun BitwardenMultiSelectButton_preview() {
AuthenticatorTheme {
BitwardenMultiSelectButton(
label = "Label",
options = persistentListOf("a", "b"),
selectedOption = "",
onOptionSelected = {},
)
}
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.model
/**
* Data class representing the data needed to create a tooltip icon in a composable.
*
* @property onClick A lambda function that defines the action to be performed when the tooltip icon
* is clicked.
* @property contentDescription A text description of the icon for accessibility purposes.
*/
data class TooltipData(
val onClick: () -> Unit,
val contentDescription: String,
)

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.util
import android.content.res.Configuration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Provides the maximum height [Dp] common for all dialogs with a given [Configuration].
*/
val Configuration.maxDialogHeight: Dp
get() = when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> 312.dp
Configuration.ORIENTATION_PORTRAIT -> 542.dp
Configuration.ORIENTATION_UNDEFINED -> Dp.Unspecified
@Suppress("DEPRECATION")
Configuration.ORIENTATION_SQUARE,
-> Dp.Unspecified
else -> Dp.Unspecified
}

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportHeight="16"
android:viewportWidth="16">
<group>
<clip-path android:pathData="M0.5,0.5h15v15h-15z" />
<path
android:fillColor="#175DDC"
android:pathData="M7.995,12.65C7.552,12.649 7.123,12.467 6.789,12.138L0.866,6.361C0.701,6.2 0.585,5.986 0.532,5.747C0.479,5.509 0.492,5.258 0.571,5.029C0.639,4.811 0.764,4.622 0.931,4.487C1.098,4.352 1.297,4.279 1.502,4.277L14.489,4.213C14.694,4.213 14.895,4.284 15.063,4.418C15.23,4.551 15.357,4.739 15.427,4.956C15.506,5.185 15.522,5.436 15.47,5.675C15.418,5.914 15.302,6.129 15.138,6.292L9.208,12.13C8.874,12.464 8.442,12.649 7.995,12.65Z" />
</group>
</vector>

View File

@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="14dp"
android:viewportHeight="14"
android:viewportWidth="14">
<group>
<clip-path android:pathData="M0.333,0.333h13.333v13.333h-13.333z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M7,12.833C10.222,12.833 12.833,10.222 12.833,7C12.833,3.778 10.222,1.167 7,1.167C3.778,1.167 1.167,3.778 1.167,7C1.167,10.222 3.778,12.833 7,12.833ZM7,13.667C10.682,13.667 13.667,10.682 13.667,7C13.667,3.318 10.682,0.333 7,0.333C3.318,0.333 0.333,3.318 0.333,7C0.333,10.682 3.318,13.667 7,13.667Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M5.469,4.142C5.143,4.443 4.917,4.911 4.917,5.594C4.917,5.824 4.73,6.01 4.5,6.01C4.27,6.01 4.083,5.824 4.083,5.594C4.083,4.714 4.382,4.01 4.904,3.529C5.421,3.054 6.115,2.833 6.844,2.833C7.572,2.833 8.267,3.054 8.783,3.529C9.305,4.01 9.604,4.714 9.604,5.594C9.604,6.036 9.384,6.395 9.133,6.678C8.898,6.943 8.594,7.188 8.322,7.407C8.307,7.419 8.292,7.432 8.277,7.444C7.978,7.684 7.718,7.899 7.528,8.124C7.343,8.344 7.26,8.533 7.26,8.719V9.5C7.26,9.73 7.074,9.917 6.844,9.917C6.614,9.917 6.427,9.73 6.427,9.5V8.719C6.427,8.261 6.638,7.887 6.891,7.586C7.141,7.29 7.467,7.026 7.754,6.795C7.761,6.789 7.768,6.784 7.774,6.778C8.068,6.542 8.322,6.337 8.509,6.126C8.698,5.912 8.771,5.744 8.771,5.594C8.771,4.911 8.545,4.443 8.219,4.142C7.887,3.837 7.409,3.667 6.844,3.667C6.278,3.667 5.8,3.837 5.469,4.142Z" />
<path
android:fillColor="#175DDC"
android:pathData="M7.417,11.271C7.417,11.559 7.183,11.792 6.896,11.792C6.608,11.792 6.375,11.559 6.375,11.271C6.375,10.983 6.608,10.75 6.896,10.75C7.183,10.75 7.417,10.983 7.417,11.271Z" />
</group>
</vector>

View File

@@ -38,4 +38,8 @@
<string name="add_code">Add code</string>
<string name="sync_items_with_bitwarden">Sync items from Bitwarden</string>
<string name="import_items">Import items</string>
<string name="issuer">Issuer</string>
<string name="totp_code">TOTP Code</string>
<string name="username">Username</string>
<string name="type">Type</string>
</resources>