mirror of
https://github.com/bitwarden/android.git
synced 2026-04-26 19:08:37 -05:00
Allow users to save items to local storage (#18)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ fun VerificationCodeItem.toDisplayItem(alertThresholdSeconds: Int) =
|
||||
VerificationCodeDisplayItem(
|
||||
id = id,
|
||||
label = label,
|
||||
issuer = issuer,
|
||||
supportingLabel = username,
|
||||
timeLeftSeconds = timeLeftSeconds,
|
||||
periodSeconds = periodSeconds,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
12
app/src/main/res/drawable/ic_region_select_dropdown.xml
Normal file
12
app/src/main/res/drawable/ic_region_select_dropdown.xml
Normal 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>
|
||||
20
app/src/main/res/drawable/ic_tooltip_small.xml
Normal file
20
app/src/main/res/drawable/ic_tooltip_small.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user