diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemScreen.kt index c50fce3c15..b24966fa49 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemScreen.kt @@ -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, + ) + } + } + } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemViewModel.kt index 1ce9f9a83e..86562dd6b1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/ItemViewModel.kt @@ -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, + val itemDataState: DataState, ) : Internal() /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/ItemData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/ItemData.kt deleted file mode 100644 index 3cf37be686..0000000000 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/ItemData.kt +++ /dev/null @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/TotpCodeItemData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/TotpCodeItemData.kt index 8835053b95..66fe8920c9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/TotpCodeItemData.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/item/model/TotpCodeItemData.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index f4bbb58b3d..c645c1ff4f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index 48cdcad489..735c6a6162 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt index d23890e598..eccf1b4d2e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt index 02e63abd21..a8d5c2733b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt @@ -19,6 +19,7 @@ fun VerificationCodeItem.toDisplayItem(alertThresholdSeconds: Int) = VerificationCodeDisplayItem( id = id, label = label, + issuer = issuer, supportingLabel = username, timeLeftSeconds = timeLeftSeconds, periodSeconds = periodSeconds, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt index 18e64e65cc..a84e3dd51a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -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), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt index 8c937e4d6f..897c9b5e4d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -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( 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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt new file mode 100644 index 0000000000..31ce01e3ae --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionDialog.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt new file mode 100644 index 0000000000..c1e8485633 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenSelectionRow.kt @@ -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, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt new file mode 100644 index 0000000000..b9ce846d0f --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dropdown/BitwardenMultiSelectButton.kt @@ -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, + 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 = {}, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt new file mode 100644 index 0000000000..a08d45aee1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/model/TooltipData.kt @@ -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, +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt new file mode 100644 index 0000000000..28fba1b7ef --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt @@ -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 + } diff --git a/app/src/main/res/drawable/ic_region_select_dropdown.xml b/app/src/main/res/drawable/ic_region_select_dropdown.xml new file mode 100644 index 0000000000..48378de39d --- /dev/null +++ b/app/src/main/res/drawable/ic_region_select_dropdown.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tooltip_small.xml b/app/src/main/res/drawable/ic_tooltip_small.xml new file mode 100644 index 0000000000..149dd5ae8b --- /dev/null +++ b/app/src/main/res/drawable/ic_tooltip_small.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 945a6581d1..ec3369a6c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,4 +38,8 @@ Add code Sync items from Bitwarden Import items + Issuer + TOTP Code + Username + Type