diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aeea32e40..c9855c7b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ Add Element Search Open website - Copy to clipboard Add new Element diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt new file mode 100644 index 000000000..fbd895a6a --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.core.ui.clipboard + +import android.content.ClipData +import android.content.ClipDescription +import android.os.Build +import android.os.PersistableBundle +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.toClipEntry + +suspend fun Clipboard.setText(label: String, text: String, sensitive: Boolean = false) { + val clipData = ClipData.newPlainText(label, text).apply { + if (sensitive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + + setClipEntry(clipData.toClipEntry()) +} diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt index 8e2b192eb..661ab5421 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt @@ -21,7 +21,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.semantics.onClick as onClickAction @Immutable data class KeyGoCardProperties( @@ -68,40 +70,92 @@ fun KeyGoCard( elevation = elevation, border = border, ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - leadingItem?.let { - Box(modifier = Modifier.minimumInteractiveComponentSize()) { - leadingItem() - } - } + KeyGoCardContent( + title = title, + leadingItem = leadingItem, + trailingItem = trailingItem, + content = content + ) + } + } +} - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.bodySmall - ) { - title() - } +@Composable +fun KeyGoCard( + onClick: () -> Unit, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + onClickLabel: String? = null, + properties: KeyGoCardProperties = KeyGoCardProperties.outlined(), + leadingItem: @Composable (() -> Unit)? = null, + trailingItem: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + // Semantics apply innermost first, so Card's own clickable has already written its click + // action with a null label by the time this runs. Setting an accessibility action merges field + // by field, so a null action here keeps that click, which is what carries the enabled state. + val labelled = + if (onClickLabel == null) modifier + else modifier.semantics { onClickAction(label = onClickLabel, action = null) } - CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.bodyLarge - ) { - content() - } - } + with(properties) { + Card( + onClick = onClick, + modifier = labelled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + ) { + KeyGoCardContent( + title = title, + leadingItem = leadingItem, + trailingItem = trailingItem, + content = content + ) + } + } +} - trailingItem?.let { - Box(modifier = Modifier.minimumInteractiveComponentSize()) { - trailingItem() - } - } +@Composable +private fun KeyGoCardContent( + title: @Composable () -> Unit, + leadingItem: @Composable (() -> Unit)?, + trailingItem: @Composable (() -> Unit)?, + content: @Composable ColumnScope.() -> Unit, +) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + leadingItem?.let { + Box(modifier = Modifier.minimumInteractiveComponentSize()) { + leadingItem() + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodySmall + ) { + title() + } + + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodyLarge + ) { + content() + } + } + + trailingItem?.let { + Box(modifier = Modifier.minimumInteractiveComponentSize()) { + trailingItem() } } } -} \ No newline at end of file +} diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt index ac78cb882..92d8f9546 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt @@ -1,5 +1,28 @@ package de.davis.keygo.core.ui.theme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily -val KeyGoTypography = Typography() \ No newline at end of file +val KeyGoTypography = Typography() + +private val Secret = TextStyle( + fontFamily = FontFamily.Monospace, + fontFeatureSettings = "tnum", +) + +/** + * The ambient text style with secret typography applied: monospace so lookalike characters stay + * apart, tabular figures so digits keep their column while a value scrolls. + * + * Use it for anything the user reads character by character, such as passwords, card numbers, CVVs + * and TOTP codes. + * + * [FontFamily.Monospace] resolves to whatever font the device ships under that alias. That is + * usually Roboto Mono, but it varies by OEM and none of them guarantee a slashed zero. Bundling a + * font to make that deterministic is a change to [Secret] alone. + */ +val secretTextStyle: TextStyle + @Composable get() = LocalTextStyle.current.merge(Secret) diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index cd5acd76b..84cb42e63 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ No matches found - Copy to clipboard Show password Hide password diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt index 8304b6992..5aa85be33 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt @@ -1,12 +1,8 @@ package de.davis.keygo.feature.autofill.presentation.activity -import android.content.ClipData -import android.content.ClipDescription import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle -import android.os.PersistableBundle import android.service.autofill.Dataset import android.view.autofill.AutofillManager import androidx.activity.compose.setContent @@ -14,7 +10,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController @@ -22,6 +18,7 @@ import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter import de.davis.keygo.core.identity.presentation.useAdapter import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.ui.clipboard.setText import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -37,6 +34,7 @@ import de.davis.keygo.feature.autofill.presentation.model.Request import de.davis.keygo.feature.autofill.presentation.model.RequestData import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import org.koin.androidx.compose.koinViewModel +import de.davis.keygo.core.item.R as CoreItemR /** @@ -67,20 +65,18 @@ internal class AutofillActivity : FragmentActivity() { val biometricUnlockAdapter = rememberBiometricUnlockAdapter() val clipboard = LocalClipboard.current + val passwordLabel = stringResource(CoreItemR.string.password) ObserveAsEvents(viewModel.events) { event -> when (event) { AutofillEvent.Abort -> cancel() is AutofillEvent.Fill -> { event.copyToClipboard?.let { - val clipData = ClipData.newPlainText(it, it).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - description.extras = PersistableBundle().apply { - putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) - } - } - - clipboard.setClipEntry(clipData.toClipEntry()) + clipboard.setText( + label = passwordLabel, + text = it, + sensitive = true, + ) } finishWithResult(event.dataset) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt index 3c5fd4997..03b338e75 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt @@ -1,12 +1,10 @@ package de.davis.keygo.feature.credit_card.presentation -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Contactless -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -17,7 +15,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,10 +48,8 @@ private fun ScanCardPrompt( modifier: Modifier = Modifier, ) { KeyGoCard( - modifier = modifier - .fillMaxWidth() - .clip(CardDefaults.elevatedShape) - .clickable(onClick = onClick), + onClick = onClick, + modifier = modifier.fillMaxWidth(), properties = KeyGoCardProperties.elevated(), leadingItem = { Icon( diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt new file mode 100644 index 000000000..cd56d7e31 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt @@ -0,0 +1,116 @@ +package de.davis.keygo.feature.item.core.presentation + +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.ui.clipboard.setText +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.feature.item.core.R +import kotlinx.coroutines.launch + +fun LazyListScope.entry( + title: String, + leadingIcon: ImageVector, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + item(key = title) { + EntryCard( + title = title, + leadingIcon = leadingIcon, + modifier = modifier.animateItem(), + trailingContent = trailingContent, + onClick = null, + onClickLabel = null, + content = content, + ) + } +} + +fun LazyListScope.copyableEntry( + title: String, + leadingIcon: ImageVector, + dataToCopy: () -> String, + sensitive: Boolean = false, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + item(key = title) { + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + val context = LocalContext.current + val copiedMessage = stringResource(R.string.copied, title) + + EntryCard( + title = title, + leadingIcon = leadingIcon, + modifier = modifier.animateItem(), + trailingContent = trailingContent, + onClick = { + scope.launch { + clipboard.setText( + label = title, + text = dataToCopy(), + sensitive = sensitive, + ) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } + }, + onClickLabel = stringResource(R.string.copy_entry, title), + content = content, + ) + } +} + +@Composable +private fun EntryCard( + title: String, + leadingIcon: ImageVector, + modifier: Modifier, + trailingContent: @Composable (() -> Unit)?, + onClick: (() -> Unit)?, + onClickLabel: String?, + content: @Composable () -> Unit, +) { + val cardTitle: @Composable () -> Unit = { Text(text = title) } + val cardLeadingItem: @Composable () -> Unit = { + Icon( + imageVector = leadingIcon, + contentDescription = null, + ) + } + + if (onClick == null) + KeyGoCard( + title = cardTitle, + modifier = modifier, + leadingItem = cardLeadingItem, + trailingItem = trailingContent, + ) { + content() + } + else + KeyGoCard( + onClick = onClick, + title = cardTitle, + modifier = modifier, + onClickLabel = onClickLabel, + leadingItem = cardLeadingItem, + trailingItem = trailingContent, + ) { + content() + } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt deleted file mode 100644 index 86ba0b187..000000000 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt +++ /dev/null @@ -1,44 +0,0 @@ -package de.davis.keygo.feature.item.core.presentation.component - -import android.content.ClipData -import android.content.ClipDescription -import android.os.Build -import android.os.PersistableBundle -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.toClipEntry -import androidx.compose.ui.res.stringResource -import de.davis.keygo.core.ui.R -import kotlinx.coroutines.launch - -@Composable -fun CopyToClipboardButton(data: String) { - val scope = rememberCoroutineScope() - val clipboard = LocalClipboard.current - - IconButton( - onClick = { - val clipData = - ClipData.newPlainText(data, data) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - description.extras = PersistableBundle().apply { - putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) - } - } - scope.launch { - clipboard.setClipEntry(clipData.toClipEntry()) - } - }, - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.copy_to_clipboard_content_description) - ) - } -} \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt index fbf6b55d3..8f9acd79a 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextObfuscationMode import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.OutlinedTextField @@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -41,6 +43,7 @@ import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransfor fun KeyGoFormField( state: TextFieldState, modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, isSecure: Boolean = false, label: @Composable (TextFieldLabelScope.() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, @@ -53,7 +56,7 @@ fun KeyGoFormField( onKeyboardAction: KeyboardActionHandler? = null, inputTransformation: InputTransformation? = TrimTransformation, outputTransformation: OutputTransformation? = null, - interactionSource: MutableInteractionSource? = null + interactionSource: MutableInteractionSource? = null, ) { val supportingText: @Composable (() -> Unit)? = error?.let { { @@ -79,6 +82,7 @@ fun KeyGoFormField( modifier = Modifier .weight(1f) .trimOnFocusLost(state, inputTransformation is TrimTransformation), + textStyle = textStyle, label = label, placeholder = placeholder, prefix = prefix, @@ -107,6 +111,7 @@ fun KeyGoFormField( modifier = Modifier .weight(1f) .trimOnFocusLost(state, inputTransformation is TrimTransformation), + textStyle = textStyle, label = label, placeholder = placeholder, prefix = prefix, diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt new file mode 100644 index 000000000..cf04ff405 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.feature.item.core.presentation.login + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword + +@Composable +fun UiPassword.colored( + numberColor: Color = MaterialTheme.colorScheme.primary, + symbolColor: Color = MaterialTheme.colorScheme.tertiary, +): AnnotatedString = remember(this, numberColor, symbolColor) { + buildAnnotatedString { + parts.forEach { + when (it) { + is UiPassword.Part.Letter -> append(it.text) + is UiPassword.Part.Number -> withStyle(SpanStyle(color = numberColor)) { + append(it.text) + } + + is UiPassword.Part.Symbol -> withStyle(SpanStyle(color = symbolColor)) { + append(it.text) + } + } + } + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt similarity index 56% rename from feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt rename to feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt index 14c97d270..e885cf630 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt @@ -1,16 +1,18 @@ -package de.davis.keygo.feature.item.create.presentation.password.model +package de.davis.keygo.feature.item.core.presentation.login.model data class UiPassword(val value: String) { val parts: List = value.splitByCharClassRegex() sealed interface Part { - data class Letter(val text: String) : Part - data class Number(val text: String) : Part - data class Symbol(val text: String) : Part + val text: String + + data class Letter(override val text: String) : Part + data class Number(override val text: String) : Part + data class Symbol(override val text: String) : Part } - private fun String.splitByCharClassRegex(): List { + private fun CharSequence.splitByCharClassRegex(): List { return PATTERN.findAll(this) .map { when { @@ -22,9 +24,10 @@ data class UiPassword(val value: String) { .toList() } - internal companion object { + companion object { + + private val PATTERN = Regex("""\p{L}+|\d+|[^\p{L}\d]+""") - val PATTERN = Regex("""\p{L}+|\d+|[^\p{L}\d]+""") fun String.asUiPassword() = UiPassword(this) } -} \ No newline at end of file +} diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index a2ce8e831..f416b33bf 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -27,4 +27,7 @@ This field can not be blank This input is invalid Something went wrong. Please try again. + + %s copied + Copy %s diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index 8547f4fa9..89af65ad1 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -47,6 +47,7 @@ import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.core.ui.theme.secretTextStyle import de.davis.keygo.feature.item.core.presentation.component.ChipFormGroup import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField @@ -205,6 +206,7 @@ private fun LoginReadyContent( modifier = Modifier.onFocusChanged { forceCompact = !it.hasFocus }, + textStyle = secretTextStyle, placeholder = { Text(text = stringResource(CoreItemR.string.password)) }, isSecure = true, outsideTrailingContent = { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt index 40f499fce..1b68c36a2 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt @@ -29,19 +29,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.core.ui.theme.secretTextStyle import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.item.core.presentation.login.colored import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent import de.davis.keygo.feature.item.create.presentation.password.model.UiCharacterSet -import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword import org.koin.androidx.compose.koinViewModel import de.davis.keygo.core.item.R as CoreItemR @@ -118,20 +116,8 @@ fun GeneratePasswordContent( } ) { Text( - text = buildAnnotatedString { - state.generatedPassword.parts.forEach { - when (it) { - is UiPassword.Part.Letter -> append(it.text) - is UiPassword.Part.Number -> withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { - append(it.text) - } - - is UiPassword.Part.Symbol -> withStyle(SpanStyle(color = MaterialTheme.colorScheme.tertiary)) { - append(it.text) - } - } - } - } + text = state.generatedPassword.colored(), + style = secretTextStyle, ) StrengthIndicator( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt index 1be84b25f..04e0aae25 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt @@ -6,11 +6,11 @@ import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword.Companion.asUiPassword import de.davis.keygo.feature.item.create.domain.PasswordGenerator import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiState import de.davis.keygo.feature.item.create.presentation.password.model.UiCharacterSet -import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword.Companion.asUiPassword import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt index 82b065477..c958e0cdb 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.item.create.presentation.password.model import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword internal data class GeneratePasswordUiState( val generatedPassword: UiPassword = UiPassword(""), diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt index b5e40dde5..7875b9975 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -59,18 +58,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.presentation.toImageVector -import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.VisibilityButton import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode -import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton +import de.davis.keygo.core.ui.theme.secretTextStyle import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormSuggestionField +import de.davis.keygo.feature.item.core.presentation.copyableEntry +import de.davis.keygo.feature.item.core.presentation.entry import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation import de.davis.keygo.feature.item.view.R import de.davis.keygo.feature.item.view.creditcard.model.CreditCardFieldType import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardUiEvent import de.davis.keygo.feature.item.view.login.model.ObfuscatedString -import de.davis.keygo.feature.item.view.onHold import de.davis.keygo.core.item.R as CoreItemR import de.davis.keygo.core.ui.R as CoreUiR import de.davis.keygo.feature.item.core.R as ItemCoreR @@ -172,9 +172,10 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi } if (state.holder.isNotBlank()) { - entry( + copyableEntry( title = cardholder, leadingIcon = Icons.Default.Person, + dataToCopy = { state.holder }, ) { Text(text = state.holder) } @@ -182,19 +183,22 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi val cardNum = state.cardNumber if (cardNum != null) { - entry( + copyableEntry( title = cardNumber, leadingIcon = Icons.Default.CreditCard, - modifier = Modifier.onHold { - isCardNumberHidden = !it - }, + dataToCopy = { cardNum.raw }, + sensitive = true, trailingContent = { - CopyToClipboardButton(cardNum.raw) + VisibilityButton( + isHidden = isCardNumberHidden, + onClick = { isCardNumberHidden = !isCardNumberHidden } + ) }, ) { val scrollState = rememberScrollState() Text( text = if (isCardNumberHidden) cardNum.hidden else cardNum.formatted, + style = secretTextStyle, maxLines = 1, modifier = Modifier.horizontalScroll(scrollState), ) @@ -203,19 +207,22 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi val cvvVal = state.cvv if (cvvVal != null) { - entry( + copyableEntry( title = cvv, leadingIcon = Icons.Default.Pin, - modifier = Modifier.onHold { - isCvvHidden = !it - }, + dataToCopy = { cvvVal.raw }, + sensitive = true, trailingContent = { - CopyToClipboardButton(cvvVal.raw) + VisibilityButton( + isHidden = isCvvHidden, + onClick = { isCvvHidden = !isCvvHidden } + ) }, ) { val scrollState = rememberScrollState() Text( text = if (isCvvHidden) cvvVal.hidden else cvvVal.raw, + style = secretTextStyle, maxLines = 1, modifier = Modifier.horizontalScroll(scrollState), ) @@ -223,9 +230,10 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi } if (state.expirationDate.isNotBlank()) { - entry( + copyableEntry( title = expiration, leadingIcon = Icons.Default.CalendarMonth, + dataToCopy = { state.expirationDate }, ) { Text(text = state.expirationDate) } @@ -406,32 +414,6 @@ private fun CreditCardFieldType.addIcon(): ImageVector { } } -private fun LazyListScope.entry( - title: String, - leadingIcon: ImageVector, - modifier: Modifier = Modifier, - trailingContent: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - item(key = title) { - KeyGoCard( - title = { - Text(text = title) - }, - leadingItem = { - Icon( - imageVector = leadingIcon, - contentDescription = null, - ) - }, - trailingItem = trailingContent, - modifier = modifier.animateItem(), - ) { - content() - } - } -} - @Preview @Composable private fun ViewCreditCardContentPreview() { diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt index 21a554d04..3744dace6 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets @@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -40,11 +40,11 @@ import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -53,6 +53,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.WavyProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -68,6 +69,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.newItemId @@ -75,12 +77,16 @@ import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.core.item.presentation.toImageVector -import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.VisibilityButton import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode -import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton +import de.davis.keygo.core.ui.theme.secretTextStyle import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormSuggestionField +import de.davis.keygo.feature.item.core.presentation.copyableEntry +import de.davis.keygo.feature.item.core.presentation.entry +import de.davis.keygo.feature.item.core.presentation.login.colored import de.davis.keygo.feature.item.core.presentation.login.model.FieldType +import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword.Companion.asUiPassword import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation import de.davis.keygo.feature.item.view.R @@ -89,10 +95,11 @@ import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.feature.item.view.login.model.TotpState import de.davis.keygo.feature.item.view.login.model.ViewLoginState import de.davis.keygo.feature.item.view.login.model.ViewLoginUiEvent -import de.davis.keygo.feature.item.view.onHold import de.davis.keygo.feature.totp.domain.model.TotpValue import de.davis.keygo.feature.totp.presentation.component.QRScanner import de.davis.keygo.feature.totp.presentation.component.TotpParseErrorDialog +import kotlin.math.ceil +import kotlin.time.Duration.Companion.milliseconds import de.davis.keygo.core.item.R as CoreItemR import de.davis.keygo.core.ui.R as CoreUiR import de.davis.keygo.feature.item.core.R as ItemCoreR @@ -226,19 +233,24 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) val pwd = state.password val score = state.passwordStrengthScore if (pwd != null && score != null) { - entry( + copyableEntry( title = password, leadingIcon = Icons.Default.Password, - modifier = Modifier.onHold { - isPasswordHidden = !it - }, + dataToCopy = { pwd.raw }, + sensitive = true, trailingContent = { - CopyToClipboardButton(pwd.raw) - }, + VisibilityButton( + isHidden = isPasswordHidden, + onClick = { isPasswordHidden = !isPasswordHidden } + ) + } ) { val scrollState = rememberScrollState() + val uiPassword = remember(pwd.raw) { pwd.raw.asUiPassword() } Text( - text = if (isPasswordHidden) pwd.hidden else pwd.raw, + text = if (isPasswordHidden) AnnotatedString(pwd.hidden) + else uiPassword.colored(), + style = secretTextStyle, maxLines = 1, modifier = Modifier.horizontalScroll(scrollState), ) @@ -250,18 +262,32 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) } when (totpState) { - is TotpState.HasTotp -> entry( + is TotpState.HasTotp -> copyableEntry( title = totp, leadingIcon = Icons.Default.AccessTime, + dataToCopy = { state.totpState.value.code }, + sensitive = true, trailingContent = { - CopyToClipboardButton(state.totpState.value.code) - }, + val indicatorSize = WavyProgressIndicatorDefaults.CircularContainerSize + Box( + modifier = Modifier.size(indicatorSize), + contentAlignment = Alignment.Center, + ) { + CircularWavyProgressIndicator( + progress = { progress.value }, + ) + + Text( + text = ceil(totpState.value.maxLifetime.milliseconds.inWholeSeconds * progress.value).toInt() + .toString(), + fontSize = with(LocalDensity.current) { (indicatorSize * 0.35f).toSp() }, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + ) + } + } ) { Text(text = state.totpState.formattedCode) - LinearProgressIndicator( - progress = { progress.value }, - modifier = Modifier.fillMaxWidth(), - ) } is TotpState.Error -> entry( @@ -275,9 +301,10 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) } if (state.username.isNotBlank()) { - entry( + copyableEntry( title = username, leadingIcon = Icons.Default.Person, + dataToCopy = { state.username }, ) { Text(text = state.username) } @@ -521,32 +548,6 @@ private fun FieldType.addIcon(): ImageVector { } } -private fun LazyListScope.entry( - title: String, - leadingIcon: ImageVector, - modifier: Modifier = Modifier, - trailingContent: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - item(key = title) { - KeyGoCard( - title = { - Text(text = title) - }, - leadingItem = { - Icon( - imageVector = leadingIcon, - contentDescription = null, - ) - }, - trailingItem = trailingContent, - modifier = modifier.animateItem(), - ) { - content() - } - } -} - @Preview @Composable private fun ViewLoginContentPreview() { diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt deleted file mode 100644 index b654b21c7..000000000 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt +++ /dev/null @@ -1,29 +0,0 @@ -package de.davis.keygo.feature.item.view - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed -import androidx.compose.ui.input.pointer.pointerInput - -fun Modifier.onHold(onHold: (Boolean) -> Unit) = this.pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - onHold(true) - - try { - val pointerId = down.id - do { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } - if (change == null || change.changedToUpIgnoreConsumed()) { - break - } - - change.consume() - } while (true) - } finally { - onHold(false) - } - } -} \ No newline at end of file